From 49d9fceb9e32be62c87151a2df35e96e5735789c Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:28:11 +0100 Subject: [PATCH] Add Jev verification runners and quality handoff --- .env.example | 8 + AGENTS.md | 31 +- README.md | 4 + .../cards-domain-agreement.test.ts | 215 +++++++ .../cards-domain-restore.test.ts | 180 ++++++ .../cards-domain.test-fixtures.ts | 80 +++ .../conversation-plan/cards-domain.test.ts | 172 ++++++ .../pipeline-reply-boundary.test.ts | 49 ++ .../pipeline-reply-restore.test.ts | 64 ++ .../pipeline-reply-unsafe.test.ts | 117 ++++ .../research-offers-comparison.test.ts | 178 ++++++ .../research-offers-concern.test.ts | 161 +++++ .../conversation-plan/research-offers.test.ts | 139 +++++ .../conversation-plan/review-analysis.test.ts | 64 ++ .../conversation-plan/review.test-fixtures.ts | 100 +++ .../src/conversation-plan/review.test.ts | 176 ++++++ .../scoped-save-validation.test.ts | 185 ++++++ .../scripted-environment.test.ts | 170 ++++++ .../scripted-network.test.ts | 242 ++++++++ .../scripted-runner.test-chat.ts | 26 + .../conversation-plan/scripted-runner.test.ts | 144 +++++ .../src/conversation-plan/scripted-runner.ts | 110 ++++ compose.yaml | 3 + docs/architecture.md | 16 +- docs/jev-live-browser-test.md | 60 ++ docs/jev-product-extraction.md | 173 ++++++ docs/jev-product-source-map.tsv | 288 +++++++++ docs/jev-quality-handoff.md | 82 +++ docs/jev-quality-review.md | 51 ++ docs/jev-ui-reconciliation.md | 59 ++ docs/self-hosting.md | 62 +- e2e/conversation-plan-heading.e2e.ts | 44 ++ e2e/conversation-plan-jobs.e2e.ts | 269 +++++++++ e2e/conversation-plan-layout.e2e.ts | 89 +++ e2e/conversation-plan-prompts.e2e.ts | 513 ++++++++++++++++ e2e/conversation-plan-runtime.e2e.ts | 407 +++++++++++++ e2e/conversation-plan-stress.e2e.ts | 475 +++++++++++++++ e2e/conversation-plan-ui.e2e.ts | 534 ++++++++++++++++ e2e/conversation-plan.Dockerfile | 30 + e2e/conversation-plan.Dockerfile.dockerignore | 56 ++ e2e/conversation-plan.playwright.config.ts | 113 ++++ e2e/decision-anchor-stress.e2e.ts | 515 ++++++++++++++++ e2e/decision-evidence.e2e.ts | 407 +++++++++++++ e2e/decision-prose.e2e.ts | 390 ++++++++++++ e2e/harness/scripted-environment.ts | 51 ++ e2e/harness/scripted-network-preload.ts | 7 + e2e/harness/scripted-network.ts | 59 ++ e2e/harness/scripted-preload.ts | 31 + e2e/harness/scripted-register.test.ts | 71 +++ e2e/harness/scripted-register.ts | 15 + e2e/jev-control.ts | 16 + e2e/jev-process.ts | 85 +++ e2e/jev-wire.ts | 90 +++ e2e/jev.ts | 378 ++++++++++++ e2e/planner-jobs.ts | 27 + e2e/research-offer-fixtures.ts | 245 ++++++++ e2e/research-offers-ui.e2e.ts | 408 +++++++++++++ e2e/research-offers.e2e.ts | 200 ++++++ e2e/sidecar-navigation.e2e.ts | 509 ++++++++++++++++ e2e/source/analysis-host-linked.native.ts | 91 +++ e2e/source/analysis-host-native.ts | 98 +++ e2e/source/analysis-host-retries.native.ts | 132 ++++ e2e/source/analysis-host.fixture.ts | 167 +++++ e2e/source/analysis-host.native.ts | 188 ++++++ e2e/source/card-gap-native.ts | 103 ++++ e2e/source/card-gap.fixture.ts | 140 +++++ e2e/source/card-gap.native.ts | 110 ++++ e2e/source/collapse-deletion-native.ts | 100 +++ e2e/source/collapse-deletion.fixture.ts | 148 +++++ e2e/source/collapse-deletion.native.ts | 201 ++++++ .../conversation-announcements.native.ts | 318 ++++++++++ e2e/source/decision-reader-native.ts | 95 +++ e2e/source/decision-reader.fixture.ts | 160 +++++ e2e/source/decision-reader.native.ts | 194 ++++++ e2e/source/diagnostic-controls.native.ts | 76 +++ e2e/source/evidence-hover-native.ts | 89 +++ e2e/source/evidence-hover.fixture.ts | 103 ++++ e2e/source/evidence-hover.native.ts | 173 ++++++ .../excerpt-correction-lifecycle.native.ts | 64 ++ e2e/source/excerpt-correction-native.ts | 64 ++ e2e/source/excerpt-correction-retry.native.ts | 70 +++ e2e/source/excerpt-correction.fixture.ts | 123 ++++ e2e/source/open-card-native.ts | 177 ++++++ e2e/source/open-card.fixture.ts | 110 ++++ e2e/source/open-card.native.ts | 136 +++++ e2e/source/playwright.config.ts | 32 + e2e/source/question-actions.native.ts | 128 ++++ .../question-terminal-origin.fixture.ts | 80 +++ e2e/source/question-terminal-origin.native.ts | 199 ++++++ e2e/source/question-view-contracts.fixture.ts | 110 ++++ e2e/source/question-view-contracts.native.ts | 282 +++++++++ e2e/source/room-source-native.ts | 140 +++++ e2e/source/room-source.fixture.ts | 121 ++++ e2e/source/room-source.native.ts | 280 +++++++++ e2e/source/source-highlight.native.ts | 218 +++++++ e2e/source/transcript-source.fixture.ts | 86 +++ e2e/source/transcript-source.native.ts | 178 ++++++ scripts/compose.test.ts | 3 + scripts/conversation-plan-worker.test.ts | 331 ++++++++++ scripts/conversation-plan-worker.ts | 571 ++++++++++++++++++ scripts/conversation-plan.ts | 367 +++++++++++ 101 files changed, 15954 insertions(+), 45 deletions(-) create mode 100644 apps/server/src/conversation-plan/cards-domain-agreement.test.ts create mode 100644 apps/server/src/conversation-plan/cards-domain-restore.test.ts create mode 100644 apps/server/src/conversation-plan/cards-domain.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/cards-domain.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-reply-restore.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts create mode 100644 apps/server/src/conversation-plan/research-offers-comparison.test.ts create mode 100644 apps/server/src/conversation-plan/research-offers-concern.test.ts create mode 100644 apps/server/src/conversation-plan/research-offers.test.ts create mode 100644 apps/server/src/conversation-plan/review-analysis.test.ts create mode 100644 apps/server/src/conversation-plan/review.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/review.test.ts create mode 100644 apps/server/src/conversation-plan/scoped-save-validation.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-environment.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-network.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-runner.test-chat.ts create mode 100644 apps/server/src/conversation-plan/scripted-runner.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-runner.ts create mode 100644 docs/jev-live-browser-test.md create mode 100644 docs/jev-product-extraction.md create mode 100644 docs/jev-product-source-map.tsv create mode 100644 docs/jev-quality-handoff.md create mode 100644 docs/jev-quality-review.md create mode 100644 docs/jev-ui-reconciliation.md create mode 100644 e2e/conversation-plan-heading.e2e.ts create mode 100644 e2e/conversation-plan-jobs.e2e.ts create mode 100644 e2e/conversation-plan-layout.e2e.ts create mode 100644 e2e/conversation-plan-prompts.e2e.ts create mode 100644 e2e/conversation-plan-runtime.e2e.ts create mode 100644 e2e/conversation-plan-stress.e2e.ts create mode 100644 e2e/conversation-plan-ui.e2e.ts create mode 100644 e2e/conversation-plan.Dockerfile create mode 100644 e2e/conversation-plan.Dockerfile.dockerignore create mode 100644 e2e/conversation-plan.playwright.config.ts create mode 100644 e2e/decision-anchor-stress.e2e.ts create mode 100644 e2e/decision-evidence.e2e.ts create mode 100644 e2e/decision-prose.e2e.ts create mode 100644 e2e/harness/scripted-environment.ts create mode 100644 e2e/harness/scripted-network-preload.ts create mode 100644 e2e/harness/scripted-network.ts create mode 100644 e2e/harness/scripted-preload.ts create mode 100644 e2e/harness/scripted-register.test.ts create mode 100644 e2e/harness/scripted-register.ts create mode 100644 e2e/jev-control.ts create mode 100644 e2e/jev-process.ts create mode 100644 e2e/jev-wire.ts create mode 100644 e2e/jev.ts create mode 100644 e2e/planner-jobs.ts create mode 100644 e2e/research-offer-fixtures.ts create mode 100644 e2e/research-offers-ui.e2e.ts create mode 100644 e2e/research-offers.e2e.ts create mode 100644 e2e/sidecar-navigation.e2e.ts create mode 100644 e2e/source/analysis-host-linked.native.ts create mode 100644 e2e/source/analysis-host-native.ts create mode 100644 e2e/source/analysis-host-retries.native.ts create mode 100644 e2e/source/analysis-host.fixture.ts create mode 100644 e2e/source/analysis-host.native.ts create mode 100644 e2e/source/card-gap-native.ts create mode 100644 e2e/source/card-gap.fixture.ts create mode 100644 e2e/source/card-gap.native.ts create mode 100644 e2e/source/collapse-deletion-native.ts create mode 100644 e2e/source/collapse-deletion.fixture.ts create mode 100644 e2e/source/collapse-deletion.native.ts create mode 100644 e2e/source/conversation-announcements.native.ts create mode 100644 e2e/source/decision-reader-native.ts create mode 100644 e2e/source/decision-reader.fixture.ts create mode 100644 e2e/source/decision-reader.native.ts create mode 100644 e2e/source/diagnostic-controls.native.ts create mode 100644 e2e/source/evidence-hover-native.ts create mode 100644 e2e/source/evidence-hover.fixture.ts create mode 100644 e2e/source/evidence-hover.native.ts create mode 100644 e2e/source/excerpt-correction-lifecycle.native.ts create mode 100644 e2e/source/excerpt-correction-native.ts create mode 100644 e2e/source/excerpt-correction-retry.native.ts create mode 100644 e2e/source/excerpt-correction.fixture.ts create mode 100644 e2e/source/open-card-native.ts create mode 100644 e2e/source/open-card.fixture.ts create mode 100644 e2e/source/open-card.native.ts create mode 100644 e2e/source/playwright.config.ts create mode 100644 e2e/source/question-actions.native.ts create mode 100644 e2e/source/question-terminal-origin.fixture.ts create mode 100644 e2e/source/question-terminal-origin.native.ts create mode 100644 e2e/source/question-view-contracts.fixture.ts create mode 100644 e2e/source/question-view-contracts.native.ts create mode 100644 e2e/source/room-source-native.ts create mode 100644 e2e/source/room-source.fixture.ts create mode 100644 e2e/source/room-source.native.ts create mode 100644 e2e/source/source-highlight.native.ts create mode 100644 e2e/source/transcript-source.fixture.ts create mode 100644 e2e/source/transcript-source.native.ts create mode 100644 scripts/conversation-plan-worker.test.ts create mode 100644 scripts/conversation-plan-worker.ts create mode 100644 scripts/conversation-plan.ts diff --git a/.env.example b/.env.example index 845f169c..c6466218 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ HARNESS=copilot-sdk BACKGROUND_JOBS=on WEB_RESEARCH=on +# Optional conversation-derived cards prototype; independent of AGENT. +CONVERSATION_PLAN=off +JEV_MODEL=jev-latest +# Per Jev request, in milliseconds (100โ€“60000; default 30000). +JEV_TIMEOUT_MS=30000 +# Required only when CONVERSATION_PLAN=on. Keep this key on the server. +JEV_API_KEY= + # GitHub App sign-in. # Remote VM mode overrides this local origin in child processes; see docs/exe-dev.md. APP_ORIGIN=http://127.0.0.1:8787 diff --git a/AGENTS.md b/AGENTS.md index 00a1f940..ae5beefb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,9 +100,10 @@ external implementation runs are durable. - **The dialect is an allowlist.** Document MDX is parsed and rendered, never evaluated. Keep imports, exports, expressions, raw HTML, and unknown JSX out. - **Records own decisions.** Question answers and accepted comment decisions live - in sidecar records. Their document components are projections. Planner - operations protect those projections, but browser CRDT validation does not - yet cross-check them against records; do not treat the projection as authority. + in sidecar records. Their document components are projections. Browser CRDT + validation allows unchanged projections to move but rejects their creation, + removal or alteration. Domain operations update records and projections together; + do not treat the projection as authority. - **Admission is not authorization.** Optional user and organization lists admit an identity. Browser routes, sockets, and Planner tools separately recheck the App installation and repository role. @@ -139,13 +140,12 @@ replayed automatically because it may already have made durable tool changes. ## Questions, comments, and anchors -Question submission currently claims the shared draft with `claimSubmit()`, -projects the answer and authoritative record inside the room lock, calls -`Store.stage()`, persists the document or sidecar, and invokes the returned -finalizer. `stage()` removes the open draft before persistence, and the failure -path does not restore it after a storage error. Treat this as a known durability -gap: a proper two-phase refactor must retain or restore the draft until the -fenced commit succeeds. +Question submission claims the shared draft with `claimSubmit()`, then stages +the document, authoritative record, and copied question maps inside the room +lock. Publish the candidate only after its fenced commit and invoke the returned +`Store.stage()` finalizer afterward. A failed commit releases the claim and keeps +the accepted draft available for retry. Planner asks and cancellation follow the +same persistence-before-publication ordering. A decision's definition is frozen except for appended options. Any writer may send `question:option` to add one while the question is open (at most 10 options, @@ -160,8 +160,9 @@ renders and derives. Anchors combine Yjs relative positions with canonical block digests. A position survives surrounding edits; a digest can recover one unique block after a move or epoch replacement. Ambiguous matches must orphan rather than guess. The safe -ordering is to rebase against the old document before a server-authored edit; -the current Planner path reconciles first and is a known recovery gap. +ordering is to rebase against the old document before a server-authored edit. +Scoped decision-prose jobs follow that ordering. The ordinary `edit_plan` tool +still reconciles first and remains a recovery gap. The browser starts a comment from a bounded quote locator, selected length, offset hint, and block indices, not an unbounded copy of selected text. The @@ -279,9 +280,9 @@ so merge ranges from all mounted editors before replacing a registry entry. after `send()` loses edits on disconnect. - Rebuilds and reconnects must replay unacknowledged updates only when the epoch is still compatible. -- Idle eviction removes a room registry entry before its asynchronous close and - checkpoint completes. Until that lifecycle is serialized, avoid opening a - replacement room during close and test revision conflicts around eviction. +- Room opening and closing share the document lifecycle lock. Opening waits for + an active close; eviction forgets the room only after runtime shutdown and + persistence complete. Preserve that ordering. - Sidecar restoration currently drops an invalid optional implementation graph instead of rejecting the whole sidecar. Do not generalize that fail-open behavior to other durable fields. diff --git a/README.md b/README.md index 50f436a6..a7413c5f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ and tool vocabulary remain optimized for planning. credentials in an OS vault or an explicitly consented local file. Documents, transcripts, decisions, research request staging, background-job inputs and artifacts, and token-free session records are stored in PostgreSQL. +- Optional conversation-derived cards default to off. With `CONVERSATION_PLAN=on`, + current and recent Chat messages and selected decision context are sent to + TypeSafe's Jev service for interpretation. This uses the server's `JEV_API_KEY`, + separately from the Planner's harness credentials, and continues with `AGENT=off`. - One Chopin process may write to a database at a time. Horizontal application scaling and zero-downtime rolling deployment are not supported. diff --git a/apps/server/src/conversation-plan/cards-domain-agreement.test.ts b/apps/server/src/conversation-plan/cards-domain-agreement.test.ts new file mode 100644 index 00000000..0de60a01 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain-agreement.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { + base, + julesAgrees, + mina, + optionId, + source, + withOption, + withPendingSettle, +} from "./cards-domain.test-fixtures"; + +describe("settle agreement authority", () => { + test("another member can agree with the pending option without deciding it", () => { + let state = applyInference(withPendingSettle(), { + ...base("agree", 3), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees); + expect(state.events.at(-1)?.type).toBe("settle.agreed"); + expect(state.threads[0]).toMatchObject({ + status: "exploring", + version: 4, + pendingSettle: { optionId, proposer: "mina" }, + }); + expect(state.threads[0].decision).toBeUndefined(); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("agreement requires another member and exactly the pending option", () => { + let self: Chat.Entry = { + ...julesAgrees, + id: "m4", + author: { kind: "member", handle: "mina" }, + }; + expect(() => + applyInference(withPendingSettle(), { + ...base("self", 3), + type: "settle.agreed", + source: source(self, "support"), + optionId, + }, self) + ).toThrow("agreement needs another member"); + expect(() => + applyInference(withPendingSettle(), { + ...base("different", 3), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId: "other-option", + }, julesAgrees) + ).toThrow("agreement names another option"); + expect(() => + applyInference(withOption(), { + ...base("no-proposal", 2), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("no pending proposal to settle"); + }); + + test("agreement stays classifier-sourced support by a real member", () => { + let state = withPendingSettle(); + expect(() => + applyInference(state, { + ...base("human-agree", 3, "human"), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("human events require authenticated correction"); + expect(() => + applyInference(state, { + ...base("planner-agree", 3, "planner"), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("agreement is inferred"); + expect(() => + applyInference(state, { + ...base("objection-agree", 3), + type: "settle.agreed", + source: source(julesAgrees, "objection"), + optionId, + }, julesAgrees) + ).toThrow("agreement source role disagrees"); + let agent: Chat.Entry = { + ...julesAgrees, + id: "agent-agree", + author: { kind: "agent" }, + }; + expect(() => + applyInference(state, { + ...base("agent-agree", 3), + type: "settle.agreed", + source: source(agent, "support"), + optionId, + }, agent) + ).toThrow("agreement needs another member"); + }); +}); + +describe("decision card thread authority", () => { + test("a settle proposal records its human proposer without deciding", () => { + let state = applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + expect(state.threads[0].pendingSettle).toEqual({ + optionId, + proposer: "mina", + messageId: "m2", + }); + expect(state.threads[0].status).toBe("exploring"); + expect(state.threads[0].decision).toBeUndefined(); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("a spike agreement binds the current accepted proposal without deciding", () => { + let cardId = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + let state = withOption(); + state = applyEvent(state, { + ...base("link-spike", state.threads[0]!.version), + type: "card.linked", + questionnaireId: cardId, + }); + let mei: Chat.Entry = { + id: "mei-spike", + author: { kind: "member", handle: "mei" }, + text: "I'd pick GitHub Apps for the spike;", + ts: 4, + }; + let proposal: ConversationPlan.Event = { + ...base("spike-proposal", state.threads[0]!.version), + type: "scoped-choice.proposed", + source: source(mei, "support"), + cardId, + optionId, + label: "GitHub Apps", + scope: "spike", + }; + state = applyInference(state, proposal, mei); + let rob: Chat.Entry = { + id: "rob-spike", + author: { kind: "member", handle: "rob" }, + text: "yep, GitHub Apps for the spike.", + ts: 5, + }; + let agreement: ConversationPlan.Event = { + ...base("spike-agreement", state.threads[0]!.version), + type: "scoped-choice.agreed", + source: source(rob, "support"), + proposalId: proposal.id, + cardId, + optionId, + label: "GitHub Apps", + scope: "spike", + }; + expect(() => applyInference(state, { ...agreement, proposalId: "prior-proposal" }, rob)) + .toThrow("scoped choice agreement does not match the current proposal"); + let agreed = applyInference(state, agreement, rob); + expect(agreed.events.at(-1)).toMatchObject({ + type: "scoped-choice.agreed", + proposalId: proposal.id, + }); + expect(agreed.threads[0]?.pendingScopedChoice?.proposalId).toBe(proposal.id); + expect(agreed.threads[0]?.pendingSettle).toBeUndefined(); + expect(agreed.threads[0]?.decision).toBeUndefined(); + expect(restoreState(JSON.parse(JSON.stringify(agreed))).threads).toEqual(agreed.threads); + }); + + test("settle proposals need a known option and a member's resolution quote", () => { + let state = withOption(); + let valid = { + ...base("settle", 2), + type: "settle.suggested" as const, + source: source(mina, "resolution"), + optionId, + }; + expect(() => + applyInference(state, { + ...valid, + optionId: "unknown", + }, mina) + ).toThrow("unknown settle option"); + let agent: Chat.Entry = { ...mina, author: { kind: "agent" } }; + expect(() => + applyInference(state, { + ...valid, + source: source(agent, "resolution"), + }, agent) + ).toThrow("human member required to settle"); + expect(() => + applyInference(state, { + ...valid, + source: source(mina, "option"), + }, mina) + ).toThrow("settle source role disagrees"); + let decided = applyEvent(state, { + ...base("card-decision", 2, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => applyInference(decided, { ...valid, observedThreadVersion: 3 }, mina)) + .toThrow("thread is not open for settling"); + }); +}); diff --git a/apps/server/src/conversation-plan/cards-domain-restore.test.ts b/apps/server/src/conversation-plan/cards-domain-restore.test.ts new file mode 100644 index 00000000..26a6bac6 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain-restore.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat } from "@chopin/protocol"; +import { applyInference, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { base, jules, mina, optionId, source, withOption } from "./cards-domain.test-fixtures"; + +describe("decision card thread authority", () => { + test("a thread links one decision card and restores that link", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + expect(state.threads[0].questionnaireId).toBe("01K0N4TR8K7JGM4R1J7PW4R8YJ"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + expect(() => + applyEvent(state, { + ...base("second-link", 3), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YK", + }) + ).toThrow("thread already has a card"); + }); + + test("a card decision clears the proposal, and a person can discard its thread", () => { + let state = applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + state = applyEvent(state, { + ...base("card-decision", 3, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(state.threads[0].pendingSettle).toBeUndefined(); + state = applyEvent(state, { + ...base("card-discard", 4, "human"), + type: "thread.discarded", + }); + expect(state.threads[0].status).toBe("discarded"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("sourced Planner chat contributions still apply and restore", () => { + let optionMessage: Chat.Entry = { + id: "agent-option", + author: { kind: "agent" }, + text: "Use a managed identity provider", + ts: 4, + }; + let reasonMessage: Chat.Entry = { + ...optionMessage, + id: "agent-reason", + text: "It handles account recovery", + }; + let state = applyInference(withOption(), { + ...base("classifier:0123456789abcdef01234567", 2, "planner"), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { + id: "classifier:89abcdef0123456789abcdef", + text: optionMessage.text, + authoring: "quoted", + }, + }, optionMessage); + state = applyInference(state, { + ...base("classifier:fedcba9876543210fedcba98", 3, "planner"), + type: "reason.added", + source: source(reasonMessage, "reason"), + contribution: { + id: "classifier:76543210fedcba9876543210", + text: reasonMessage.text, + authoring: "quoted", + targetId: optionId, + }, + }, reasonMessage); + expect(state.threads[0].contributions.slice(-2).map((item) => item.kind)).toEqual([ + "option", + "reason", + ]); + let saved = JSON.parse(JSON.stringify(state)); + let quotedOption: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + expect(restoreState(saved, [jules, quotedOption, optionMessage, reasonMessage]).threads) + .toEqual(saved.threads); + expect(() => + applyInference(state, { + ...base("classifier:111111111111111111111111", 4, "planner"), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { + id: "classifier:222222222222222222222222", + text: optionMessage.text, + authoring: "human-edited", + }, + }, optionMessage) + ).toThrow("inference cannot claim human wording"); + }); + + test("historical classifier decisions still replay and restore", () => { + let state = applyEvent(withOption(), { + ...base("historic-decision", 2), + type: "decision.recorded", + source: source(mina, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }); + state = applyEvent(state, { + ...base("historic-reopen", 3), + type: "decision.reopened", + source: source(mina, "reopening"), + explicit: true, + }); + let saved = JSON.parse(JSON.stringify(state)); + expect(replay(saved.events).threads).toEqual(saved.threads); + let quotedOption: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + expect(restoreState(saved, [jules, quotedOption, mina]).threads).toEqual(saved.threads); + expect(state.threads[0].decisionHistory).toHaveLength(1); + }); + + test("restore rejects tampered card links, proposals, and malformed new events", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + state = applyInference(state, { + ...base("settle", 3), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + let saved = JSON.parse(JSON.stringify(state)); + let wrongLink = structuredClone(saved); + wrongLink.threads[0].questionnaireId = "other-card"; + expect(() => restoreState(wrongLink)).toThrow(/snapshot/i); + let wrongProposal = structuredClone(saved); + wrongProposal.threads[0].pendingSettle.optionId = "other-option"; + expect(() => restoreState(wrongProposal)).toThrow(/snapshot/i); + let malformedEvent = structuredClone(saved); + malformedEvent.events[2].source = source(mina, "resolution"); + expect(() => restoreState(malformedEvent)).toThrow(/unknown/i); + expect(() => + applyEvent(state, { + ...base("forged-discard", 4), + type: "thread.discarded", + }) + ).toThrow("only a person discards"); + }); + + test("the thread limit allows twenty and rejects a twenty-first", () => { + let state = initialState(); + for (let index = 0; index < 20; index++) { + let message = { ...jules, id: `question-${index}` }; + state = applyInference(state, { + ...base(`open-${index}`, 0), + threadId: `thread-${index}`, + type: "thread.opened", + source: source(message, "question"), + question: message.text, + }, message); + } + expect(state.threads).toHaveLength(20); + let message = { ...jules, id: "question-20" }; + expect(() => + applyInference(state, { + ...base("open-20", 0), + threadId: "thread-20", + type: "thread.opened", + source: source(message, "question"), + question: message.text, + }, message) + ).toThrow("conversation thread limit reached"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); +}); diff --git a/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts b/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts new file mode 100644 index 00000000..b5c8970c --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts @@ -0,0 +1,80 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; + +export let optionId = "01K0N4W3B7P27CBAEC7A8C8WEA"; +export let jules: Chat.Entry = { + id: "m1", + author: { kind: "member", handle: "jules" }, + text: "What auth system should we use?", + ts: 1, +}; +export let mina: Chat.Entry = { + id: "m2", + author: { kind: "member", handle: "mina" }, + text: "Let's use GitHub Apps.", + ts: 2, +}; +export let julesAgrees: Chat.Entry = { + id: "m3", + author: { kind: "member", handle: "jules" }, + text: "Sounds good to me.", + ts: 3, +}; + +export function source( + message: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role, + }; +} + +export function base( + id: string, + version: number, + origin: "classifier" | "human" | "planner" = "classifier", +): ConversationPlan.EventBase { + return { + id, + threadId: "t1", + observedThreadVersion: version, + origin, + actor: origin === "human" + ? { kind: "member", handle: "mina" } + : origin === "planner" + ? { kind: "agent" } + : { kind: "classifier" }, + at: 3, + }; +} + +export function withOption(): ConversationPlan.State { + let state = applyInference(initialState(), { + ...base("open", 0), + type: "thread.opened", + source: source(jules, "question"), + question: jules.text, + }, jules); + let optionMessage: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + return applyInference(state, { + ...base("option", 1), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { id: optionId, text: optionMessage.text, authoring: "quoted" }, + }, optionMessage); +} + +export function withPendingSettle(): ConversationPlan.State { + return applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); +} diff --git a/apps/server/src/conversation-plan/cards-domain.test.ts b/apps/server/src/conversation-plan/cards-domain.test.ts new file mode 100644 index 00000000..736c154a --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { base, mina, optionId, source, withOption } from "./cards-domain.test-fixtures"; + +describe("decision card thread authority", () => { + test("new classifier and planner decisions cannot enter through inference", () => { + let state = withOption(); + for (let origin of ["classifier", "planner"] as const) { + let message = origin === "classifier" ? mina : { + ...mina, + author: { kind: "agent" as const }, + }; + expect(() => + applyInference(state, { + ...base(`decision-${origin}`, 2, origin), + type: "decision.recorded", + source: source(message, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }, message) + ).toThrow("decisions are recorded on the card"); + } + }); + + test("new inferred reopenings and resolution candidates cannot bypass the card", () => { + let decided = applyEvent(withOption(), { + ...base("old-decision", 2), + type: "decision.recorded", + source: source(mina, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => + applyInference(decided, { + ...base("new-reopen", 3), + type: "decision.reopened", + source: source(mina, "reopening"), + explicit: true, + }, mina) + ).toThrow("decisions are recorded on the card"); + expect(() => + applyInference(withOption(), { + ...base("new-candidate", 2), + type: "candidate.proposed", + source: source(mina, "resolution"), + candidate: { id: "candidate", kind: "resolution", text: "GitHub Apps" }, + }, mina) + ).toThrow("decisions are recorded on the card"); + }); + + test("human and Planner options enter without a chat quote", () => { + let state = applyEvent(withOption(), { + ...base("human-option", 2, "human"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: "Build our own", + authoring: "human-edited", + }, + }); + state = applyEvent(state, { + ...base("planner-option", 3, "planner"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEC", + text: "Use an identity provider", + authoring: "scribe", + }, + }); + expect(state.threads[0].contributions.slice(-2)).toMatchObject([ + { actor: { kind: "member", handle: "mina" }, sources: [] }, + { actor: { kind: "agent" }, sources: [] }, + ]); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("source-free card options enforce ULIDs and origin-specific wording", () => { + let valid = { + ...base("card-option", 2, "human"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: "Build our own", + authoring: "human-edited", + }, + } as const satisfies ConversationPlan.Event; + let state = withOption(); + expect(() => + applyEvent(state, { + ...valid, + contribution: { ...valid.contribution, id: "not-a-ulid" }, + }) + ).toThrow("card option IDs must be ULIDs"); + expect(() => + applyEvent(state, { + ...valid, + contribution: { ...valid.contribution, authoring: "quoted" }, + }) + ).toThrow("card option authoring disagrees with origin"); + expect(() => + applyEvent(state, { + ...valid, + ...base("planner-incorrect", 2, "planner"), + }) + ).toThrow("card option authoring disagrees with origin"); + expect(() => + applyEvent(state, { + ...valid, + ...base("planner-bad-id", 2, "planner"), + contribution: { ...valid.contribution, id: "not-a-ulid", authoring: "scribe" }, + }) + ).toThrow("card option IDs must be ULIDs"); + expect(() => + applyEvent(state, { + ...valid, + ...base("card:planner-option:source", 2, "planner"), + source: source({ ...mina, author: { kind: "agent" } }, "option"), + }) + ).toThrow("only card options can omit a source"); + expect(() => + applyEvent(state, { + ...valid, + source: source(mina, "option"), + }) + ).toThrow("human action cannot claim a message quote"); + }); + + test("old correction actions cannot change a linked card's decision or status", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + let correct = (change: ConversationPlan.CorrectionChange, current = state) => + applyCorrection( + current, + { + actionId: `correction-${change.kind}`, + threadId: "t1", + expectedVersion: current.threads[0].version, + change, + }, + { kind: "member", handle: "mina" }, + 4, + ); + expect(() => correct({ kind: "record-decision", text: "GitHub Apps", optionId })) + .toThrow("decisions are recorded on the card"); + let decided = applyEvent(state, { + ...base("card-decision", 3, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => correct({ kind: "set-status", status: "reopened" }, decided)) + .toThrow("decisions are recorded on the card"); + expect(() => correct({ kind: "edit", field: "decision", text: "Other choice" }, decided)) + .toThrow("decisions are recorded on the card"); + let candidate = applyInference(decided, { + ...base("reopening-candidate", 4), + type: "candidate.proposed", + source: source(mina, "reopening"), + candidate: { id: "reopen", kind: "reopening", text: "Reopen this" }, + }, mina); + expect(() => correct({ kind: "confirm-candidate", candidateId: "reopen" }, candidate)) + .toThrow("decisions are recorded on the card"); + }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts b/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts new file mode 100644 index 00000000..1c49f17d --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { askJev } from "./jev"; +import { question, reply, validAnswers } from "./pipeline-reply.test-fixtures"; + +test("retains complete validated distributions and resolved model", async () => { + let result = await askJev({ state: { current: { text: "Synthetic" } }, questions: question }, { + apiKey: "test-only", + fetch: async () => reply(validAnswers), + }); + expect(result.model).toBe("jev-1.13.0"); + expect((result.answers.act as any).probabilities).toEqual({ yes: 0.9, no: 0.1 }); + expect((result.answers.level as any).probabilities).toEqual({ "0": 0.1, "1": 0, "2": 0.9 }); +}); + +test.each( + [ + [{ ...validAnswers, flag: { type: "noul", noul: Number.NaN } }, "invalid"], + [{ ...validAnswers, act: { ...validAnswers.act, probabilities: { yes: 1 } } }, "invalid"], + [{ ...validAnswers, act: { ...validAnswers.act, choice: "missing" } }, "invalid"], + [{ ...validAnswers, level: { ...validAnswers.level, score: 500 } }, "invalid"], + [{ + ...validAnswers, + level: { ...validAnswers.level, legend: { "0": "Wrong", "1": "Some", "2": "All" } }, + }, "invalid"], + [{ ...validAnswers, extra: { type: "noul", noul: 0.5 } }, "invalid"], + ] as const, +)("rejects malformed answer %#", async (answers, error) => { + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + fetch: async () => reply(answers), + })).rejects.toThrow(error); +}); + +test("bounds errors and aborts a stalled request", async () => { + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + fetch: async () => new Response("secret body", { status: 503 }), + })).rejects.toThrow("503"); + let abort = new AbortController(); + abort.abort(); + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + signal: abort.signal, + fetch: async (_, init) => { + if (init?.signal?.aborted) throw new Error("aborted"); + return reply(validAnswers); + }, + })).rejects.toThrow(); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts b/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts new file mode 100644 index 00000000..9e0a8054 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { initialState, restoreState } from "./domain"; +import { validAnswers } from "./pipeline-reply.test-fixtures"; + +test("JSON restore keeps typed answers and rejects unbounded legends", () => { + let state = initialState(); + state.analysis.push({ + messageId: "synthetic", + questionSetVersion: "v1", + modelVersion: "jev-1.13.0", + status: "unlinked", + eventIds: [], + passes: [{ stage: "triage", answers: structuredClone(validAnswers) as any }], + }); + let restored = restoreState(JSON.parse(JSON.stringify(state))); + expect((restored.analysis[0].passes[0].answers.level as any).legend["2"]).toBe("All"); + let unbounded = structuredClone(state); + (unbounded.analysis[0].passes[0].answers.level as any).legend["2"] = "x".repeat(501); + expect(() => restoreState(unbounded)).toThrow("legend"); + let malformed = structuredClone(state); + (malformed.analysis[0].passes[0].answers.act as any).probabilities.yes = Infinity; + expect(() => restoreState(malformed)).toThrow("probability"); + let bounded = structuredClone(state); + bounded.analysis[0].outcomes = [{ + start: 0, + end: 9, + status: "review", + gate: "unclear", + eventIds: [], + }]; + expect(restoreState(JSON.parse(JSON.stringify(bounded))).analysis[0].outcomes?.[0].gate) + .toBe("unclear"); + bounded.analysis[0].outcomes[0].gate = "x".repeat(301); + expect(() => restoreState(bounded)).toThrow("candidate outcome"); +}); + +test("restores exactly 45 analysis answers and rejects a 46th", () => { + let state = initialState(); + let answers = Object.fromEntries(Array.from({ length: 46 }, (_, index) => [ + `q${index}`, + { type: "noul" as const, noul: 0.5 }, + ])); + state.analysis.push({ + messageId: "bounded", + questionSetVersion: "v4", + modelVersion: "jev-1.13.0", + status: "unlinked", + eventIds: [], + passes: [{ + stage: "targeting", + answers: Object.fromEntries( + Object.entries(answers).slice(0, 45), + ), + }], + }); + expect(Object.keys( + restoreState(JSON.parse(JSON.stringify(state))).analysis[0].passes[0] + .answers, + )).toHaveLength(45); + state.analysis[0].passes[0].answers = answers; + expect(() => restoreState(JSON.parse(JSON.stringify(state)))).toThrow( + "too many analysis questions", + ); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts b/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts new file mode 100644 index 00000000..3148bab1 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { extractQuotes } from "./quotes"; + +test.each( + [ + [ + "capitalized because qualifier", + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "capitalized when-configured qualifier", + "When configured, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "capitalized as-fallback qualifier", + "As Fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "quoted", + 'what sends transactional notifications? "our SMTP relay, Postmark, or SES?"', + ], + [ + "reported", + 'Dan said, "what sends transactional notifications? our SMTP relay, Postmark, or SES?"', + ], + [ + "negated", + "not what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "conditional", + "if cost wins, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "inline conditional qualifier", + "what sends transactional notifications if cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "inline when qualifier", + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "inline topic attribution", + "what sends transactional notifications according to Dan? our SMTP relay, Postmark, or SES?", + ], + [ + "reported inline topic", + "what sends transactional notifications as Dan reported? our SMTP relay, Postmark, or SES?", + ], + [ + "inline unless qualifier", + "what sends transactional notifications unless cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "reported inline question", + "Dan said what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "slash-conjoined providers", + "what sends transactional notifications? our SMTP relay / Postmark, SES, or Mailgun?", + ], + [ + "rationale in first item", + "what sends transactional notifications? our SMTP relay is cheap, Postmark, or SES?", + ], + [ + "because rationale in first item", + "what sends transactional notifications? our SMTP relay because cheap, Postmark, or SES?", + ], + [ + "capitalized because list item", + "what sends transactional notifications? our SMTP relay, Because, or SES?", + ], + [ + "capitalized when-configured list item", + "what sends transactional notifications? our SMTP relay, When configured, or SES?", + ], + [ + "capitalized as-fallback list item", + "what sends transactional notifications? our SMTP relay, As Fallback, or SES?", + ], + [ + "duplicate-item", + "what sends transactional notifications? our SMTP relay, Postmark, or SMTP relay?", + ], + ] as const, +)("does not split an unsafe D03 provider list into choices", (_, text) => { + let quotes = extractQuotes(text); + expect(quotes.some(({ quote }) => + [ + "SMTP relay", + "Postmark", + "SES", + "Because", + "When configured", + "As Fallback", + ].includes(quote) + )) + .toBe(false); +}); + +test.each([ + `Rob said, "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model."`, + `"Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model."`, + "Not Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Tiptap, bare ProseMirror, Lexical, or Tiptap.", +])("quoted, negated, or duplicate four-option lists stay whole: %s", text => { + expect(extractQuotes(text)).not.toHaveLength(4); +}); + +test("a fifth explicit option fails the source budget", () => { + expect(() => + extractQuotes( + "Tiptap, bare ProseMirror, Lexical, Slate, or just native Selection and Range with our own document model.", + ) + ).toThrow("source quote count exceeds 4"); +}); diff --git a/apps/server/src/conversation-plan/research-offers-comparison.test.ts b/apps/server/src/conversation-plan/research-offers-comparison.test.ts new file mode 100644 index 00000000..72596a72 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers-comparison.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { actOnResearchOffer, offerResearch, restoreState } from "./domain"; +import { renderResearchTask } from "./validation"; +import { applyEvent } from "./events"; +import { + ada, + message, + optionThread, + r2Id, + s3Id, + taskProposal, +} from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("freezes two current option labels and a deterministic cost brief", () => { + let entry = message("cost-source", "R2 egress could matter for thumbnails."); + let state = optionThread(); + let offer = taskProposal(entry, state); + let saved = offerResearch(state, offer, entry); + expect(saved.researchOffers?.[0]?.brief).toBe( + "Compare current costs for S3 and R2. Discussion context: โ€œR2 egress could matter for thumbnails.โ€", + ); + let accepted = actOnResearchOffer(saved, offer.id, { + id: "accept-cost", + kind: "research", + actor: ada, + principalId: "ada-id", + at: 10, + }); + expect(restoreState(accepted, [entry])).toEqual(accepted); + let linked = applyEvent(accepted, { + id: "link", + type: "card.linked", + threadId: "hosting", + observedThreadVersion: accepted.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 11, + questionnaireId: "card-1", + }); + let relabeled = applyEvent(linked, { + id: "rename", + type: "option.relabeled", + threadId: "hosting", + observedThreadVersion: linked.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 12, + optionId: r2Id, + label: "Cloudflare R2", + observedCardRevision: 0, + }); + expect(restoreState(relabeled, [entry])).toEqual(relabeled); + expect(relabeled.researchOffers?.[0]?.task?.options[1].labelAtOffer).toBe("R2"); + expect(offerResearch(relabeled, offer, entry)).toBe(relabeled); + }); + + test("rejects invented, foreign, duplicate, or changed task labels and briefs", () => { + let entry = message("cost-source", "Compare our current providers."); + let state = optionThread(); + let base = taskProposal(entry, state); + let altered = (change: (offer: typeof base) => void) => { + let candidate = structuredClone(base); + change(candidate); + return candidate; + }; + expect(() => + offerResearch( + state, + altered(offer => { + offer.brief = "Invented task"; + }), + entry, + ) + ) + .toThrow(/brief/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].labelAtOffer = "Azure Blob"; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].id = "foreign"; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + let otherThread = applyEvent(state, { + id: "open-other", + type: "thread.opened", + threadId: "other", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 8, + question: "Which queue?", + }); + otherThread = applyEvent(otherThread, { + id: "add-other", + type: "option.added", + threadId: "other", + observedThreadVersion: otherThread.threads[1]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 9, + contribution: { + id: "01K00000000000000000000003", + text: "Redis", + authoring: "scribe", + }, + }); + expect(() => + offerResearch( + otherThread, + altered(offer => { + offer.task!.options[1] = { + id: "01K00000000000000000000003", + labelAtOffer: "Redis", + }; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].id = s3Id; + }), + entry, + ) + ).toThrow(/duplicate/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].labelAtOffer = "x".repeat(161); + }), + entry, + ) + ).toThrow(/text/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.threadId = "foreign-thread"; + }), + entry, + ) + ).toThrow(/thread/); + let saved = offerResearch(state, base, entry); + let forged = structuredClone(saved); + forged.researchOffers![0].task!.options[1].labelAtOffer = "Azure Blob"; + forged.researchOffers![0].brief = renderResearchTask( + forged.researchOffers![0].task!, + forged.researchOffers![0].source, + ); + expect(() => restoreState(forged, [entry])).toThrow(/history/); + let wrongSource = structuredClone(saved); + wrongSource.researchOffers![0].source.quote = "forged exact quote"; + wrongSource.researchOffers![0].source.end = "forged exact quote".length; + wrongSource.researchOffers![0].brief = renderResearchTask( + wrongSource.researchOffers![0].task!, + wrongSource.researchOffers![0].source, + ); + expect(() => restoreState(wrongSource, [entry])).toThrow(/source quote/); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offers-concern.test.ts b/apps/server/src/conversation-plan/research-offers-concern.test.ts new file mode 100644 index 00000000..fd9f87cc --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers-concern.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { restoreState } from "./domain"; +import { renderResearchTask } from "./validation"; +import { applyEvent } from "./events"; +import { + ada, + costConcernProposal, + type CostConcernTask, + diskId, + exactSource, + mailgunId, + message, + offerCostConcern, + postmarkId, + r2Id, + s3Id, + sesId, + stateWithOptions, +} from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("generic current-price uncertainty freezes all providers without a chosen focus", () => { + let entry = message("d03-cost-source", "I don't know current provider prices."); + let state = stateWithOptions("providers", "Which email provider?", [ + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + { id: mailgunId, label: "Mailgun" }, + ]); + let candidate = costConcernProposal(entry, state, undefined, entry.text, "providers"); + let saved = offerCostConcern(state, candidate, entry); + let offer = saved.researchOffers?.[0]; + if (!offer) throw new Error("cost concern was not stored"); + expect((offer.task as unknown as CostConcernTask).kind).toBe("current-cost-concern"); + expect((offer.task as unknown as CostConcernTask).options).toEqual([ + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + { id: mailgunId, labelAtOffer: "Mailgun" }, + ]); + expect((offer.task as unknown as CostConcernTask).focusOptionId).toBeUndefined(); + expect(offer.source).toEqual(exactSource(entry)); + expect(offer.brief).toBe(renderResearchTask( + offer.task as unknown as ConversationPlan.ResearchTask, + offer.source, + )); + for (let label of ["Postmark", "Amazon SES", "Mailgun", entry.text]) { + expect(offer.brief).toContain(label); + } + expect(restoreState(structuredClone(saved), [entry])).toEqual(saved); + }); + + test("an R2 egress concern focuses R2 while retaining every storage option as context", () => { + let entry = message("d04-egress-source", "R2 egress could matter for thumbnails."); + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let candidate = costConcernProposal(entry, state, r2Id); + let saved = offerCostConcern(state, candidate, entry); + let offer = saved.researchOffers?.[0]; + if (!offer) throw new Error("cost concern was not stored"); + expect((offer.task as unknown as CostConcernTask).kind).toBe("current-cost-concern"); + expect((offer.task as unknown as CostConcernTask).options).toEqual([ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + { id: diskId, labelAtOffer: "Local disk" }, + ]); + expect((offer.task as unknown as CostConcernTask).focusOptionId).toBe(r2Id); + expect(offer.source).toEqual(exactSource(entry)); + expect(offer.brief).toBe(renderResearchTask( + offer.task as unknown as ConversationPlan.ResearchTask, + offer.source, + )); + for (let label of ["Amazon S3", "Cloudflare R2", "Local disk", entry.text]) { + expect(offer.brief).toContain(label); + } + expect(restoreState(structuredClone(saved), [entry])).toEqual(saved); + }); + + test("multi-option concerns reject stale labels, duplicate IDs, and an invalid focus", () => { + let entry = message("d04-stale-source", "R2 egress could matter for thumbnails."); + entry.ts = 1_800_000_000; + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let candidate = costConcernProposal(entry, state, r2Id); + let offered = offerCostConcern(state, candidate, entry); + let renamed = applyEvent(offered, { + id: "human:rename-r2", + type: "card.corrected", + threadId: "storage", + observedThreadVersion: offered.threads[0]!.version, + origin: "human", + actor: ada, + at: 1_800_000_001_000, + change: { kind: "edit", field: "contribution", contributionId: r2Id, text: "Backblaze B2" }, + }); + expect(restoreState(structuredClone(renamed), [entry])).toEqual(renamed); + let afterRename = message("d04-after-rename", entry.text); + afterRename.ts = 1_800_000_002; + let stale = costConcernProposal(afterRename, state, r2Id); + expect(() => offerCostConcern(renamed, stale, afterRename)).toThrow(/current thread/); + expect(() => restoreState(structuredClone(renamed), [{ ...entry, ts: afterRename.ts }])) + .toThrow(/postdates/); + + let duplicate = structuredClone(candidate); + duplicate.task.options[2]!.id = duplicate.task.options[1]!.id; + duplicate.brief = renderResearchTask( + duplicate.task as unknown as ConversationPlan.ResearchTask, + duplicate.source, + ); + expect(() => offerCostConcern(state, duplicate, entry)).toThrow(/duplicate/); + + let invalidFocus = structuredClone(candidate); + invalidFocus.task.focusOptionId = postmarkId; + invalidFocus.brief = renderResearchTask( + invalidFocus.task as unknown as ConversationPlan.ResearchTask, + invalidFocus.source, + ); + let rejected = false; + try { + let result = offerCostConcern(state, invalidFocus, entry); + rejected = result.researchOffers?.length !== 1; + } catch { + rejected = true; + } + expect(rejected).toBe(true); + }); + + test("an old R2-only quote cannot become a generic concern after R2 is relabeled", () => { + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let renamed = applyEvent(state, { + id: "human:rename-r2-before-new-concern", + type: "card.corrected", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: ada, + at: 1_800_000_001_000, + change: { kind: "edit", field: "contribution", contributionId: r2Id, text: "Backblaze B2" }, + }); + let entry = message("d04-old-r2-name", "R2 egress could matter for thumbnails."); + let candidate = costConcernProposal(entry, renamed, undefined); + let rejected = false; + try { + let result = offerCostConcern(renamed, candidate, entry); + rejected = result.researchOffers?.length !== 1; + } catch { + rejected = true; + } + expect(candidate.task.focusOptionId).toBeUndefined(); + expect(rejected).toBe(true); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offers.test.ts b/apps/server/src/conversation-plan/research-offers.test.ts new file mode 100644 index 00000000..5d5f868a --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { actOnResearchOffer, initialState, offerResearch, restoreState } from "./domain"; +import { MAX_RESEARCH_OFFERS } from "./validation"; +import { ada, bo, message, proposal } from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("keeps exact UTF-16 source and brief through restore; old sidecars remain valid", () => { + let entry = message("m1"); + let input = proposal(entry); + let state = offerResearch(initialState(), input, entry); + input.source.quote = "changed"; + expect(state.researchOffers?.[0]?.brief).toBe(entry.text); + expect(state.researchOffers?.[0]?.source.end).toBe(entry.text.length); + expect(restoreState(structuredClone(state), [entry])).toEqual(state); + let { researchOffers: _offers, ...old } = initialState(); + expect(restoreState(old).researchOffers).toBeUndefined(); + expect(offerResearch(old, proposal(entry), entry).researchOffers).toHaveLength(1); + }); + + test("rejects forged quote, range, author, brief and missing thread on creation or restore", () => { + let entry = message("m1"); + let base = proposal(entry); + let badQuote = structuredClone(base); + badQuote.source.quote = "forged"; + expect(() => offerResearch(initialState(), badQuote, entry)).toThrow(); + let badRange = structuredClone(base); + badRange.source.end -= 1; + expect(() => offerResearch(initialState(), badRange, entry)).toThrow(); + let badAuthor = structuredClone(base); + badAuthor.source.author = { kind: "member", handle: "bo" }; + expect(() => offerResearch(initialState(), badAuthor, entry)).toThrow(); + let badBrief = structuredClone(base); + badBrief.brief = "polished but not the quote"; + expect(() => offerResearch(initialState(), badBrief, entry)).toThrow(); + expect(() => + offerResearch( + initialState(), + { + ...base, + status: "accepted", + } as typeof base, + entry, + ) + ).toThrow(/proposal/); + expect(() => offerResearch(initialState(), { ...base, threadId: "absent" }, entry)) + .toThrow(/thread/); + let saved = offerResearch(initialState(), base, entry); + expect(() => restoreState(saved, [])).toThrow(/missing/); + let altered = structuredClone(saved); + altered.researchOffers![0].source.quote = "forged"; + expect(() => restoreState(altered, [entry])).toThrow(); + }); + + test("first terminal action wins, exact retries are no-ops, conflicting clicks fail", () => { + let entry = message("m1"); + let offered = offerResearch(initialState(), proposal(entry), entry); + expect(offerResearch(offered, proposal(entry), entry)).toBe(offered); + let research: ConversationPlan.ResearchAction = { + id: "act-1", + kind: "research", + actor: ada, + principalId: "ada-id", + at: 2, + }; + let accepted = actOnResearchOffer(offered, "offer:m1", research); + expect(accepted.researchOffers?.[0]).toMatchObject({ status: "accepted", action: research }); + expect(actOnResearchOffer(accepted, "offer:m1", research)).toBe(accepted); + expect(actOnResearchOffer(accepted, "offer:m1", { ...research, at: 99 })).toBe(accepted); + expect(accepted.researchOffers?.[0]?.action?.at).toBe(2); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + id: "act-2", + kind: "dismiss", + actor: bo, + principalId: "bo-id", + at: 3, + }) + ).toThrow(/terminal/); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + ...research, + principalId: "different-id", + }) + ).toThrow(/terminal/); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + ...research, + actor: bo, + }) + ).toThrow(/terminal/); + expect(restoreState(accepted, [entry])).toEqual(accepted); + let invalid = structuredClone(accepted); + invalid.researchOffers![0].status = "dismissed"; + expect(() => restoreState(invalid)).toThrow(/disagrees/); + let unacted = structuredClone(accepted); + unacted.researchOffers![0].action = undefined; + expect(() => restoreState(unacted)).toThrow(); + let noPrincipal = structuredClone(accepted); + noPrincipal.researchOffers![0].action!.principalId = ""; + expect(() => restoreState(noPrincipal)).toThrow(); + }); + + test("retains dismissed need/context suppression, permits a new context, fails closed at cap", () => { + let first = message("m1"); + let dismissed = actOnResearchOffer( + offerResearch(initialState(), proposal(first), first), + "offer:m1", + { id: "dismiss-1", kind: "dismiss", actor: ada, principalId: "ada-id", at: 2 }, + ); + let second = message("m2"); + expect(() => offerResearch(dismissed, proposal(second), second)).toThrow(/duplicate/); + let changed = offerResearch( + dismissed, + proposal(second, "offer:m2", "hosting-terms", "v2"), + second, + ); + expect(changed.researchOffers?.[0]?.status).toBe("dismissed"); + expect(changed.researchOffers).toHaveLength(2); + expect(() => + actOnResearchOffer(changed, "offer:m2", { + id: "dismiss-1", + kind: "dismiss", + actor: bo, + principalId: "bo-id", + at: 3, + }) + ).toThrow(/already used/); + let full = initialState(); + for (let index = 0; index < MAX_RESEARCH_OFFERS; index++) { + let entry = message(`m${index}`); + full = offerResearch(full, proposal(entry, `offer:${index}`, `need:${index}`), entry); + } + expect(() => + offerResearch(full, proposal(message("overflow"), "overflow", "new"), message("overflow")) + ).toThrow(/full/); + expect(full.researchOffers).toHaveLength(MAX_RESEARCH_OFFERS); + }); +}); diff --git a/apps/server/src/conversation-plan/review-analysis.test.ts b/apps/server/src/conversation-plan/review-analysis.test.ts new file mode 100644 index 00000000..3781b715 --- /dev/null +++ b/apps/server/src/conversation-plan/review-analysis.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { completeAnalysis, enqueue, initialState, retryMessage } from "./domain"; +import { message } from "./review.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. + +describe("reviewed conversation-plan analysis invariants", () => { + test("failed queued analysis needs an explicit retry before completion", () => { + let entry = message("m1", "A question?"); + let state = enqueue(initialState(), entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "failed", + passes: [], + error: "temporary failure", + }); + expect(() => + completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }) + ).toThrow(/retry|pending|queued/i); + state = retryMessage(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + expect(state.queue).toEqual([]); + }); + + test("keeps at least 26 message diagnostics while remaining bounded", () => { + let state = initialState(); + for (let index = 0; index < 26; index++) { + let entry = message(`analysis-${index}`, "No planning change"); + state = enqueue(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + } + expect(state.analysis).toHaveLength(26); + expect(state.analysis[0].messageId).toBe("analysis-0"); + for (let index = 26; index < 66; index++) { + let entry = message(`analysis-${index}`, "No planning change"); + state = enqueue(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + } + expect(state.analysis).toHaveLength(64); + expect(state.analysis[0].messageId).toBe("analysis-2"); + }); +}); diff --git a/apps/server/src/conversation-plan/review.test-fixtures.ts b/apps/server/src/conversation-plan/review.test-fixtures.ts new file mode 100644 index 00000000..2e210e07 --- /dev/null +++ b/apps/server/src/conversation-plan/review.test-fixtures.ts @@ -0,0 +1,100 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference } from "./domain"; +import { applyEvent } from "./events"; + +// Exact actor/helper declarations: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. +export let alice = { kind: "member", handle: "alice" } as const; +export let bob = { kind: "member", handle: "bob" } as const; + +export function message(id: string, text: string, author: Chat.Author = alice): Chat.Entry { + return { id, text, author, ts: 1 }; +} + +export function source( + entry: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote: entry.text, + start: 0, + end: entry.text.length, + role, + }; +} + +export function open( + state: ConversationPlan.State, + threadId = "t1", + entry = message(`question-${threadId}`, `Should we use ${threadId}?`), +): ConversationPlan.State { + return applyInference(state, { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }, entry); +} + +export function addOption( + state: ConversationPlan.State, + threadId: string, + optionId: string, +): ConversationPlan.State { + let entry = message(`option-${optionId}`, `Option ${optionId}`); + let thread = state.threads.find((item) => item.id === threadId)!; + return applyInference(state, { + id: `add-${optionId}`, + type: "option.added", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: source(entry, "option"), + contribution: { id: optionId, text: entry.text, authoring: "quoted" }, + }, entry); +} + +export function decide(state: ConversationPlan.State, optionId?: string): ConversationPlan.State { + return applyEvent(state, { + id: "resolve-event", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 3, + text: "Do that", + optionId, + explicit: true, + }); +} + +export function correct( + state: ConversationPlan.State, + actionId: string, + threadId: string, + change: ConversationPlan.CorrectionChange, + author: typeof alice | typeof bob = bob, +): ConversationPlan.State { + let thread = state.threads.find((item) => item.id === threadId)!; + return applyCorrection( + state, + { + actionId, + threadId, + expectedVersion: thread.version, + change, + }, + author, + 4, + ); +} diff --git a/apps/server/src/conversation-plan/review.test.ts b/apps/server/src/conversation-plan/review.test.ts new file mode 100644 index 00000000..c64e4748 --- /dev/null +++ b/apps/server/src/conversation-plan/review.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference, initialState, restoreState } from "./domain"; +import { + addOption, + alice, + bob, + correct, + decide, + message, + open, + source, +} from "./review.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. + +describe("reviewed conversation-plan invariants", () => { + test("a question edit attributes the correcting member, distinct from its source speaker", () => { + let state = open(initialState()); + state = correct(state, "edit-question", "t1", { + kind: "edit", + field: "question", + text: "Should the outline be optional?", + }); + expect(state.threads[0].questionEditedBy).toBe("bob"); + expect(state.threads[0].questionSources[0].author).toEqual(alice); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads[0].questionEditedBy).toBe("bob"); + }); + + test("reopened status needs a current decision and retains its history", () => { + let state = open(initialState()); + expect(() => + correct(state, "reopen-too-early", "t1", { + kind: "set-status", + status: "reopened", + }) + ).toThrow(/decid/i); + state = decide(state); + state = correct(state, "reopen-now", "t1", { kind: "set-status", status: "reopened" }); + expect(state.threads[0].status).toBe("reopened"); + expect(state.threads[0].decision).toBeUndefined(); + expect(state.threads[0].decisionHistory).toHaveLength(1); + }); + + test("an option with a historical decision link cannot move after reopening", () => { + let state = open(initialState()); + state = addOption(state, "t1", "o1"); + state = decide(state, "o1"); + state = correct(state, "reopen", "t1", { kind: "set-status", status: "reopened" }); + state = open(state, "t2"); + expect(() => + correct(state, "move-o1", "t1", { + kind: "move", + contributionId: "o1", + targetThreadId: "t2", + targetVersion: 1, + }) + ).toThrow(/referenced/i); + }); + + test("moving into a full destination rejects the correction", () => { + let state = open(initialState()); + state = addOption(state, "t1", "o1"); + state = open(state, "t2"); + for (let index = 0; index < 64; index++) { + state = addOption(state, "t2", `target-${index}`); + } + expect(() => + correct(state, "overflow", "t1", { + kind: "move", + contributionId: "o1", + targetThreadId: "t2", + targetVersion: state.threads[1].version, + }) + ).toThrow(/limit|full/i); + expect(state.threads[1].contributions).toHaveLength(64); + }); + + test("unknown fields cannot carry unbounded data in events or debug snapshots", () => { + let entry = message("extra", "A question?"); + let event: ConversationPlan.Event = { + id: "extra-event", + type: "thread.opened", + threadId: "t1", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }; + let forged = { ...event, unbounded: "x".repeat(100_000) } as ConversationPlan.Event; + expect(() => applyInference(initialState(), forged, entry)).toThrow(/unknown|unexpected/i); + let snapshot = initialState() as ConversationPlan.State & { unbounded?: string }; + snapshot.analysis.push({ + messageId: "m1", + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + eventIds: [], + unbounded: "x".repeat(100_000), + } as ConversationPlan.AnalysisRecord); + expect(() => restoreState(snapshot)).toThrow(/unknown|unexpected/i); + }); + + test("question edits reject an unrelated unbounded contribution ID", () => { + let state = open(initialState()); + let failure: unknown; + try { + correct(state, "bad-question-edit", "t1", { + kind: "edit", + field: "question", + text: "Revised question", + contributionId: "x".repeat(1_000_000), + } as unknown as ConversationPlan.CorrectionChange); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/invalid|unknown|contribution/i); + }); + + test("decision edits reject an unrelated contribution ID on ingress and restore", () => { + let state = decide(open(initialState())); + let failure: unknown; + try { + correct(state, "bad-decision-edit", "t1", { + kind: "edit", + field: "decision", + text: "Revised decision", + contributionId: "o1", + } as unknown as ConversationPlan.CorrectionChange); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/invalid|unknown|contribution/i); + let valid = correct(state, "good-decision-edit", "t1", { + kind: "edit", + field: "decision", + text: "Revised decision", + }); + let saved = JSON.parse(JSON.stringify(valid)); + saved.events.at(-1).change.contributionId = "o1"; + expect(() => restoreState(saved)).toThrow(); + }); + + test("same human action ID is a noop only for an identical action", () => { + let state = open(initialState()); + let action: ConversationPlan.CorrectionAction = { + actionId: "edit-1", + threadId: "t1", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "A revised question" }, + }; + let changed = applyCorrection(state, action, bob, 4); + expect(applyCorrection(changed, action, bob, 5)).toBe(changed); + let persisted = restoreState(JSON.parse(JSON.stringify(changed))); + expect(applyCorrection(persisted, action, bob, 5)).toBe(persisted); + expect(() => + applyCorrection( + changed, + { + ...action, + change: { kind: "edit", field: "question", text: "A different question" }, + }, + bob, + 5, + ) + ).toThrow(/collision|different/i); + expect(() => applyCorrection(changed, { ...action, expectedVersion: 2 }, bob, 5)) + .toThrow(/collision|different/i); + }); +}); diff --git a/apps/server/src/conversation-plan/scoped-save-validation.test.ts b/apps/server/src/conversation-plan/scoped-save-validation.test.ts new file mode 100644 index 00000000..0fd3356e --- /dev/null +++ b/apps/server/src/conversation-plan/scoped-save-validation.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +type Event = ConversationPlan.Event; +type Saved = Extract; + +function source(handle: string, messageId: string, quote: string): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle }, + quote, + start: 0, + end: quote.length, + role: "support", + }; +} + +function fixture() { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let state = applyEvent(initialState(), { + id: "open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: { ...source("Mei", "question", "Which editor?"), role: "question" }, + question: "Which editor?", + }); + state = applyEvent(state, { + id: "link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: cardId, + }); + let proposal: Extract = { + id: "proposal", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source("Mei", "mei", "I'd pick Lexical for the spike;"), + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyEvent(state, proposal); + let agreements: Array> = []; + for (let [handle, at] of [["Rob", 4], ["Jo", 5]] as const) { + let agreement: Extract = { + id: `agreement-${handle}`, + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at, + source: source(handle, `message-${handle}`, "yep, Lexical for the spike."), + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyEvent(state, agreement); + agreements.push(agreement); + } + let saved: Saved = { + id: "save", + type: "scoped-choice.saved", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Rob" }, + at: 6, + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + supportEventIds: [proposal.id, ...agreements.map(item => item.id)], + sources: [proposal.source, ...agreements.map(item => item.source)], + expectedGeneration: 0, + }; + return { state, proposal, agreements, saved }; +} + +test("multi-support scoped Save replays exact unique source IDs in event order", () => { + let { state, proposal, agreements, saved } = fixture(); + let accepted = applyEvent(state, saved); + expect(restoreState(accepted).events.at(-1)).toEqual(saved); + let invalid: Saved[] = [ + { ...saved, supportEventIds: [proposal.id, agreements[0]!.id, agreements[0]!.id] }, + { ...saved, supportEventIds: [agreements[0]!.id, proposal.id, agreements[1]!.id] }, + { ...saved, supportEventIds: [proposal.id, "other", agreements[1]!.id] }, + { ...saved, sources: [agreements[0]!.source, proposal.source, agreements[1]!.source] }, + { ...saved, sources: [proposal.source, agreements[0]!.source, agreements[0]!.source] }, + ]; + for (let item of invalid) expect(() => applyEvent(state, item)).toThrow(); + let quote = "I withdraw my support for Lexical."; + let withdrawn = applyEvent(state, { + id: "rob-withdraws", + type: "stance.changed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: { ...source("Rob", "rob-withdrawal", quote), role: "withdrawal" }, + scopedProposalId: proposal.id, + position: "neutral", + }); + expect(() => + applyEvent(withdrawn, { + ...saved, + observedThreadVersion: withdrawn.threads[0]!.version, + }) + ).toThrow(); +}); + +test("legacy-shaped Save cannot omit a currently active supporter", () => { + let { state, proposal, agreements, saved } = fixture(); + let stale: Saved = { + ...saved, + agreementId: agreements[0]!.id, + sources: [proposal.source, agreements[0]!.source], + }; + delete stale.supportEventIds; + expect(() => applyEvent(state, stale)).toThrow(); +}); + +test("legacy-shaped Save cannot include a withdrawn supporter", () => { + let { state, proposal, agreements, saved } = fixture(); + let quote = "I withdraw my support for Lexical."; + let withdrawn = applyEvent(state, { + id: "rob-withdraws-before-legacy-save", + type: "stance.changed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: { ...source("Rob", "rob-withdrawal", quote), role: "withdrawal" }, + scopedProposalId: proposal.id, + position: "neutral", + }); + let stale: Saved = { + ...saved, + observedThreadVersion: withdrawn.threads[0]!.version, + agreementId: agreements[0]!.id, + sources: [proposal.source, agreements[0]!.source], + }; + delete stale.supportEventIds; + expect(() => applyEvent(withdrawn, stale)).toThrow(); +}); + +test("historical proposal-only and proposal-plus-agreement Saves still restore", () => { + let { state, proposal, agreements, saved } = fixture(); + for (let count of [3, 4]) { + let historical = initialState(); + for (let event of state.events.slice(0, count)) historical = applyEvent(historical, event); + let legacy: Saved = { + ...saved, + observedThreadVersion: historical.threads[0]!.version, + ...(count === 4 ? { agreementId: agreements[0]!.id } : {}), + sources: count === 4 + ? [proposal.source, agreements[0]!.source] + : [proposal.source], + }; + delete legacy.supportEventIds; + let accepted = applyEvent(historical, legacy); + expect(restoreState(accepted).events.at(-1)).toEqual(legacy); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-environment.test.ts b/apps/server/src/conversation-plan/scripted-environment.test.ts new file mode 100644 index 00000000..6d4dea48 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-environment.test.ts @@ -0,0 +1,170 @@ +import { expect, it } from "bun:test"; +import { join } from "node:path"; + +import { + requireScriptedServer, + SCRIPTED_HARNESS, +} from "../../../../e2e/harness/scripted-environment"; + +let root = "/offline/chopin"; +let approved: Record = { + E2E_CONVERSATION_PLAN: "1", + SERVER_HOST: "127.0.0.1", + PORT: "8788", + APP_ORIGIN: "http://127.0.0.1:8788", + AGENT: "on", + CONVERSATION_PLAN: "on", + HARNESS: SCRIPTED_HARNESS, + HARNESS_AUTH: "direct", + JEV_API_KEY: "e2e-jev-only", + JEV_MODEL: "jev-e2e", + TYPESAFE_API_KEY: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + NO_PROXY: "*", + no_proxy: "*", + E2E_PLANNER_JOBS_DIR: join(root, "e2e", "test-results", "planner-jobs"), + E2E_JEV_CONTROL_DIR: join(root, "e2e", "test-results", "jev-control"), + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://test:fake-password@127.0.0.1:5433/chopin?sslmode=disable", + SESSION_ENCRYPTION_KEY: "a".repeat(64), +}; + +it("accepts the explicitly selected local test environment without changing its input", () => { + let env = Object.freeze({ ...approved }); + expect(() => requireScriptedServer(env, root)).not.toThrow(); + expect(env).toEqual(approved); +}); + +let rejected: Array<[string, string | undefined]> = [ + ["E2E_CONVERSATION_PLAN", undefined], + ["E2E_CONVERSATION_PLAN", "true"], + ["SERVER_HOST", "0.0.0.0"], + ["SERVER_HOST", "localhost"], + ["PORT", "8792"], + ["PORT", "08788"], + ["APP_ORIGIN", "http://localhost:8788"], + ["APP_ORIGIN", "http://127.0.0.1:8788/"], + ["AGENT", "off"], + ["CONVERSATION_PLAN", "off"], + ["HARNESS", "e2e-fake"], + ["HARNESS_AUTH", "auto"], + ["JEV_API_KEY", "real-provider-secret"], + ["JEV_MODEL", "real-model"], + ["TYPESAFE_API_KEY", "real-provider-secret"], + ["TYPESAFE_API_KEY", undefined], + ["E2E_PLANNER_JOBS_DIR", "/tmp/planner-jobs"], + ["E2E_PLANNER_JOBS_DIR", `${approved.E2E_PLANNER_JOBS_DIR}/`], + ["E2E_JEV_CONTROL_DIR", "/tmp/jev-control"], + ["STORAGE_DRIVER", "memory"], + ["SESSION_ENCRYPTION_KEY", undefined], + ["SESSION_ENCRYPTION_KEY", ""], + ["SESSION_ENCRYPTION_KEY", " "], + ["SESSION_ENCRYPTION_KEY", "a".repeat(63)], + ["SESSION_ENCRYPTION_KEY", "z".repeat(64)], + ["DATABASE_URL", undefined], + ["DATABASE_URL", "not-a-url"], + ["DATABASE_URL", "https://127.0.0.1/chopin"], + ["DATABASE_URL", "postgresql://test:fake-password@remote.example/chopin"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?host=remote.example"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?hostaddr=192.0.2.1"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?service=production"], +]; + +for (let [index, [name, value]] of rejected.entries()) { + it(`refuses invalid ${name} (${index}) before caller effects`, () => { + let effects = 0; + let env = { ...approved, [name]: value }; + expect(() => { + requireScriptedServer(env, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); +} + +it("never includes supplied database or provider secrets in refusal messages", () => { + for (let name of ["DATABASE_URL", "JEV_API_KEY", "TYPESAFE_API_KEY"]) { + let failure: unknown; + try { + requireScriptedServer({ ...approved, [name]: "confidential-test-value" }, root); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).not.toContain("confidential-test-value"); + } +}); + +it("accepts supplied local PostgreSQL URLs without contacting their database", () => { + for (let host of ["127.0.0.1", "localhost", "[::1]"]) { + for (let protocol of ["postgres", "postgresql"]) { + expect(() => + requireScriptedServer({ + ...approved, + DATABASE_URL: `${protocol}://test:fake-password@${host}:5433/chopin`, + }, root) + ).not.toThrow(); + } + } +}); + +let proxyNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +]; +for (let name of proxyNames) { + for ( + let value of [undefined, " ", "http://proxy-user:confidential-proxy-secret@proxy.invalid:8080"] + ) { + it(`refuses transport proxy ${name} (${value === undefined ? "missing" : value === " " ? "blank" : "set"}) before caller effects`, () => { + let effects = 0; + expect(() => { + requireScriptedServer({ ...approved, [name]: value }, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); + } +} + +for (let name of ["NO_PROXY", "no_proxy"]) { + for (let [index, value] of [undefined, "", "127.0.0.1", " * "].entries()) { + it(`requires universal transport proxy bypass ${name} (${index}) before caller effects`, () => { + let effects = 0; + expect(() => { + requireScriptedServer({ ...approved, [name]: value }, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); + } +} + +it("never includes supplied transport proxy secrets in refusal messages", () => { + for (let name of [...proxyNames, "NO_PROXY", "no_proxy"]) { + let failure: unknown; + try { + requireScriptedServer({ + ...approved, + [name]: "http://proxy-user:confidential-proxy-secret@proxy.invalid:8080", + }, root); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(name); + expect((failure as Error).message).not.toContain("proxy-user"); + expect((failure as Error).message).not.toContain("confidential-proxy-secret"); + expect((failure as Error).message).not.toContain("proxy.invalid"); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-network.test.ts b/apps/server/src/conversation-plan/scripted-network.test.ts new file mode 100644 index 00000000..ef3fcb79 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-network.test.ts @@ -0,0 +1,242 @@ +import { expect, it, mock } from "bun:test"; + +import { createScriptedFetch } from "../../../../e2e/harness/scripted-network"; + +it("rejects unhandled, malformed and credential-bearing URLs before native fetch", async () => { + let native = mock(async () => new Response("unexpected network")); + let fetch = createScriptedFetch(native); + let rejected = [ + "https://provider.invalid/secret-path?token=confidential", + "https://api.github.com/user", + "https://api.githubcopilot.com/mcp/", + "http://127.0.0.1:8789/", + "http://127.0.0.1:8792/", + "http://127.0.0.1/", + "http://localhost:8788/", + "http://[::1]:8788/", + "https://127.0.0.1:8788/", + "ftp://127.0.0.1:8797/", + "file:///secret-path", + "http://confidential:password@127.0.0.1:8788/", + "http://confidential@127.0.0.1:8797/", + "not-a-url-confidential", + "/relative-confidential", + "", + ]; + for (let input of rejected) { + await expect(fetch(input)).rejects.toThrow(Error); + } + await expect(fetch(new URL("http://remote.invalid:8788/"))).rejects.toThrow(Error); + await expect(fetch(new Request("https://remote.invalid/"))).rejects.toThrow(Error); + await expect(fetch({ secret: "confidential" } as unknown as string)).rejects.toThrow(Error); + expect(native).not.toHaveBeenCalled(); +}); + +it("delegates only the two fixture origins with native redirect following disabled", async () => { + let response = new Response(null, { + status: 302, + headers: { location: "https://remote.invalid/secret-path" }, + }); + let native = mock(async (_input: Parameters[0], init?: RequestInit) => { + expect(new Request(_input, init).redirect).toBe("error"); + return response; + }); + let fetch = createScriptedFetch(native); + for (let redirect of [undefined, "follow", "manual", "error"] as const) { + for (let input of ["http://127.0.0.1:8788/health", "http://127.0.0.1:8797/mcp"]) { + expect(await fetch(input, redirect ? { redirect } : undefined)).toBe(response); + } + } + expect(native).toHaveBeenCalledTimes(8); +}); + +it("normalizes input forms and overrides while preserving body, headers and abort propagation", async () => { + let controller = new AbortController(); + let native = mock(async (input: Parameters[0], init?: RequestInit) => { + expect(input).toBeInstanceOf(Request); + expect(init).toBeUndefined(); + let request = input as Request; + expect(request.method).toBe("POST"); + expect(request.redirect).toBe("error"); + expect(request.headers.get("x-fixture")).toBe("original"); + expect(request.signal.aborted).toBe(false); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + let init = Object.freeze({ + method: "POST", + body: new Uint8Array([104, 105]), + signal: controller.signal, + headers: new Headers({ "x-fixture": "original" }), + }); + let request = new Request("http://127.0.0.1:8797/", { + method: "PUT", + body: "original request body", + redirect: "follow", + }); + for (let input of ["http://127.0.0.1:8788/", new URL("http://127.0.0.1:8797/"), request]) { + expect(await (await fetch(input, init)).text()).toBe("hi"); + } + controller.abort(); + for (let [input] of native.mock.calls) { + expect((input as Request).signal.aborted).toBe(true); + } + expect(init).not.toHaveProperty("redirect"); + expect(request.bodyUsed).toBe(false); + let inherited = createScriptedFetch(async input => { + let normalized = input as Request; + expect(normalized.method).toBe("PUT"); + expect(normalized.redirect).toBe("error"); + return new Response(await normalized.text()); + }); + expect(await (await inherited(request)).text()).toBe("original request body"); +}); + +it("guards preconnect with the same origin restriction before delegating", () => { + let native = mock(async () => new Response("unused")); + let preconnect = mock( + (_url: string | URL, _options?: Parameters[1]) => {}, + ); + let fetch = createScriptedFetch(Object.assign(native, { preconnect })); + let options = Object.freeze({ dns: true, tcp: true, https: false }); + let local = new URL("http://127.0.0.1:8797/mcp"); + fetch.preconnect("http://127.0.0.1:8788/", options); + fetch.preconnect(local, options); + expect(preconnect).toHaveBeenCalledTimes(2); + expect(preconnect.mock.calls[1]?.[0]).toBe("http://127.0.0.1:8797/mcp"); + expect(preconnect.mock.calls[1]?.[1]).toEqual({ ...options, http: undefined }); + for (let input of ["https://remote.invalid/", "http://127.0.0.1:8789/", "bad confidential"]) { + expect(() => fetch.preconnect(input)).toThrow(Error); + } + expect(preconnect).toHaveBeenCalledTimes(2); + expect(native).not.toHaveBeenCalled(); +}); + +it("offers a guarded no-op preconnect when the injected fetch has none", () => { + let native = mock(async () => new Response("unused")); + let fetch = createScriptedFetch(native); + expect(() => fetch.preconnect("http://127.0.0.1:8788/")).not.toThrow(); + expect(() => fetch.preconnect("http://127.0.0.1:8797/")).not.toThrow(); + expect(() => fetch.preconnect("https://remote.invalid/confidential")).toThrow(Error); + expect(native).not.toHaveBeenCalled(); +}); + +it("keeps refusal errors generic for malformed input and embedded secrets", async () => { + let fetch = createScriptedFetch(mock(async () => new Response("unused"))); + let messages = new Set(); + for ( + let input of [ + "confidential-malformed-input", + "http://confidential-user:confidential-password@127.0.0.1:8788/private", + "https://remote.invalid/confidential-path?token=confidential-token", + ] + ) { + let failure = await fetch(input).then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + let message = (failure as Error).message; + expect(message).not.toContain("confidential"); + expect(message).not.toContain("remote.invalid"); + messages.add(message); + let preconnectFailure: unknown; + try { + fetch.preconnect(input); + } catch (error) { + preconnectFailure = error; + } + expect(preconnectFailure).toBeInstanceOf(Error); + messages.add((preconnectFailure as Error).message); + } + expect(messages.size).toBe(1); +}); + +it("preserves inherited RequestInit fields through native request normalization", async () => { + let controller = new AbortController(); + let init = Object.create({ + method: "POST", + body: "inherited body", + headers: { "x-fixture": "inherited" }, + signal: controller.signal, + }) as RequestInit; + let native = mock(async (input: Parameters[0], options?: RequestInit) => { + let request = new Request(input, options); + expect(request.method).toBe("POST"); + expect(request.headers.get("x-fixture")).toBe("inherited"); + expect(request.signal.aborted).toBe(false); + controller.abort(); + expect(request.signal.aborted).toBe(true); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + expect(await (await fetch("http://127.0.0.1:8788/", init)).text()).toBe("inherited body"); +}); + +it("keeps a URL-mutating init getter from sending a remote destination to native fetch", async () => { + let url = new URL("http://127.0.0.1:8788/"); + let init = { + get headers() { + url.hostname = "remote.invalid"; + return { "x-fixture": "mutating getter" }; + }, + }; + let native = mock(async (input: Parameters[0], options?: RequestInit) => { + let request = new Request(input, options); + expect(request.url).toBe("http://127.0.0.1:8788/"); + return new Response("local"); + }); + let fetch = createScriptedFetch(native); + expect(await (await fetch(url, init)).text()).toBe("local"); + expect(url.hostname).toBe("remote.invalid"); +}); + +it("captures inherited preconnect options before checking a URL mutated by their getter", () => { + let url = new URL("http://127.0.0.1:8788/"); + let options = Object.create({ + get dns() { + url.hostname = "remote.invalid"; + return true; + }, + tcp: true, + }) as Parameters[1]; + let preconnect = mock( + (_input: string | URL, options?: Parameters[1]) => { + void options?.dns; + }, + ); + let fetch = createScriptedFetch(Object.assign( + mock(async () => new Response("unused")), + { preconnect }, + )); + expect(() => fetch.preconnect(url, options)).toThrow(Error); + expect(preconnect).not.toHaveBeenCalled(); +}); + +it("excludes Bun transport overrides and their getters from the native fetch boundary", async () => { + let native = mock(async (input: Parameters[0], init?: RequestInit) => { + expect(input).toBeInstanceOf(Request); + expect(init).toBeUndefined(); + expect(input).not.toHaveProperty("proxy"); + expect(input).not.toHaveProperty("unix"); + expect(input).not.toHaveProperty("tls"); + let request = input as Request; + expect(request.url).toBe("http://127.0.0.1:8788/"); + expect(request.method).toBe("POST"); + expect(request.headers.get("x-fixture")).toBe("transport overrides excluded"); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + let init = { + method: "POST", + body: "standard body", + headers: { "x-fixture": "transport overrides excluded" }, + get proxy() { + throw new Error("offline proxy override was inspected"); + }, + get unix() { + throw new Error("offline Unix override was inspected"); + }, + get tls() { + throw new Error("offline TLS override was inspected"); + }, + }; + expect(await (await fetch("http://127.0.0.1:8788/", init)).text()).toBe("standard body"); +}); diff --git a/apps/server/src/conversation-plan/scripted-runner.test-chat.ts b/apps/server/src/conversation-plan/scripted-runner.test-chat.ts new file mode 100644 index 00000000..b00979cb --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.test-chat.ts @@ -0,0 +1,26 @@ +import * as Chat from "../chat/service"; +import { documentTools } from "../agent/tools"; +import { jobTools, scopedJobTools } from "../agent/job-tools"; + +export type Room = Chat.Room; + +// The historical runner calls handlers directly; this bridge exercises current tools, not a stream. +export function planTools(context: Room) { + let room = Chat.documentRoom(context); + let tools = scopedJobTools({ + edit_plan: documentTools.edit_plan, + draft_heading: jobTools.draft_heading, + }, room); + return Object.entries(tools).map(([name, tool]) => ({ + name, + handler: (input: unknown, _context: unknown) => { + let execute = tool.execute; + if (!execute) throw new Error(`no executable tool ${name}`); + return execute(input as never, { + toolCallId: `scripted:${name}`, + messages: [], + context: { room }, + }); + }, + })); +} diff --git a/apps/server/src/conversation-plan/scripted-runner.test.ts b/apps/server/src/conversation-plan/scripted-runner.test.ts new file mode 100644 index 00000000..10077d02 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.test.ts @@ -0,0 +1,144 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as Chat from "./scripted-runner.test-chat"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { Job } from "./jobs"; +import { scriptedRunner } from "./scripted-runner"; + +let plans: Service.Plan[] = []; +let directories: string[] = []; +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + for (let directory of directories) await rm(directory, { recursive: true, force: true }); + plans = []; + directories = []; +}); + +async function directory(): Promise { + let path = await mkdtemp(join(tmpdir(), "planner-jobs-")); + directories.push(path); + return path; +} + +function heading(): Job { + return { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("a scripted heading runs the real job tool while ordinary writing is refused", async () => { + let { plan, server } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile( + join(path, "heading.json"), + JSON.stringify([ + { + tool: "edit_plan", + args: { revision: "$revision", operations: [{ op: "insert_root", source: "x\n" }] }, + }, + { + tool: "draft_heading", + args: { revision: "$revision", title: "Auth Strategy", goal: "Goal: pick auth." }, + }, + ]), + ); + let context: Chat.Room = { + chat: plan.chat, + plan, + server, + room: "test", + persist: () => Service.persist(plan), + } as Chat.Room; + let run = scriptedRunner(path, () => Chat.planTools(context), plan.chat, () => plan.revision); + expect(await run(heading(), "prompt")).toEqual({ + status: "done", + output: JSON.stringify({ title: "Auth Strategy", goal: "Goal: pick auth." }), + }); + expect(room.project(plan.document)).toBe("# Auth Strategy\n\nGoal: pick auth.\n"); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); +}); + +test("missing scripts skip, malformed scripts fail, and an active turn is never overwritten", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + let run = scriptedRunner(path, () => [], plan.chat, () => plan.revision); + expect(await run(heading(), "prompt")).toMatchObject({ status: "skipped" }); + await writeFile(join(path, "heading.json"), "{not JSON"); + expect(await run(heading(), "prompt")).toMatchObject({ status: "failed" }); + let turn = { id: "existing", handle: "chopin", started: 1, responded: false }; + plan.chat.turn = turn; + expect(await run(heading(), "prompt")).toMatchObject({ status: "failed" }); + expect(plan.chat.turn).toBe(turn); +}); + +test("a held script waits for release and aborts promptly when its job service stops", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile(join(path, "heading.json"), JSON.stringify([])); + await writeFile(join(path, "heading.hold"), "hold"); + let controller = new AbortController(); + let run = scriptedRunner(path, () => [], plan.chat, () => plan.revision, controller.signal); + let pending = run(heading(), "prompt"); + await Bun.sleep(50); + expect(plan.chat.job?.id).toBe(heading().id); + controller.abort(); + expect(await pending).toMatchObject({ status: "skipped" }); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); +}); + +test("a replaced job keeps its new turn and output after an old scripted call finishes", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile( + join(path, "heading.json"), + JSON.stringify([{ + tool: "draft_heading", + args: {}, + }]), + ); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let run = scriptedRunner( + path, + () => + [{ + name: "draft_heading", + handler: async () => { + entered.resolve(); + await release.promise; + return "{}"; + }, + }] as never, + plan.chat, + () => plan.revision, + ); + let pending = run(heading(), "prompt"); + await entered.promise; + let replacement = { ...heading(), id: "heading:document:m2" }; + let turn = { id: "new-turn", handle: "chopin", started: 2, responded: false }; + plan.chat.job = replacement; + plan.chat.turn = turn; + plan.chat.jobOutput = "new output"; + release.resolve(); + expect(await pending).toMatchObject({ status: "skipped" }); + expect(plan.chat.job).toBe(replacement); + expect(plan.chat.turn).toBe(turn); + expect(plan.chat.jobOutput).toBe("new output"); +}); diff --git a/apps/server/src/conversation-plan/scripted-runner.ts b/apps/server/src/conversation-plan/scripted-runner.ts new file mode 100644 index 00000000..e370f6ba --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.ts @@ -0,0 +1,110 @@ +/** Test-only Planner runner: real scoped tools, with calls read from a local script. */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { ulid } from "@chopin/dialect"; +import { type Call, calls, held, MAX_SCRIPT_BYTES, reason, substitute } from "./scripted-script"; + +type Tool = { + name: string; + handler?: (args: unknown, context: unknown) => unknown | Promise; +}; +import type { Chat } from "../chat/service"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobRunner } from "./planner-jobs"; + +export function scriptedRunner( + dir: string, + tools: (job: Job) => Tool[], + chat: Chat, + revision: () => number, + signal?: AbortSignal, +): PlannerJobRunner { + return async (job, _prompt, runnerSignal): Promise => { + let activeSignal = runnerSignal ?? signal; + let script: Call[]; + try { + let data = await readFile(join(dir, `${job.kind}.json`), "utf8"); + if (data.length > MAX_SCRIPT_BYTES) throw new Error("scripted Planner job is too large"); + script = calls(JSON.parse(data)); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { status: "skipped", reason: `no script for ${job.kind}` } + : { status: "failed", reason: reason(error) }; + } + if (activeSignal?.aborted || chat.closed) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + if (chat.busy || chat.turn || chat.job) { + return { status: "failed", reason: "Another Planner turn is active." }; + } + let turn: NonNullable = { + id: ulid(), + handle: "chopin", + started: Date.now(), + responded: false, + }; + chat.busy = true; + chat.turn = turn; + chat.job = job; + chat.jobOutput = undefined; + chat.jobFailures = 0; + chat.jobCalls = new Map(); + try { + let gate = await held(dir, job.kind, activeSignal); + if (gate === "timed-out") { + return { status: "failed", reason: "Scripted Planner job hold timed out." }; + } + if ( + gate !== "released" || activeSignal?.aborted || chat.closed + || chat.job !== job || chat.turn !== turn + ) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + let available = tools(job); + let failed: string | undefined; + for (let call of script) { + if (activeSignal?.aborted || chat.closed || chat.job !== job || chat.turn !== turn) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + let tool = available.find(item => item.name === call.tool); + if (!tool?.handler) return { status: "failed", reason: `no tool ${call.tool}` }; + try { + let result = await tool.handler( + substitute(call.args, revision(), job.target) as never, + {} as never, + ); + if (typeof result === "string") { + let parsed: unknown; + try { + parsed = JSON.parse(result); + } catch {} + if (parsed && typeof parsed === "object" && "ok" in parsed && !parsed.ok) { + failed = reason("error" in parsed ? parsed.error : result); + } + } + } catch (error) { + failed = reason(error); + } + if (activeSignal?.aborted || chat.closed || chat.job !== job || chat.turn !== turn) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + } + return chat.jobOutput === undefined + ? { status: "failed", reason: failed ?? "The script called no job tool successfully." } + : { status: "done", output: chat.jobOutput }; + } catch (error) { + return { status: "failed", reason: reason(error) }; + } finally { + if (chat.job === job && chat.turn === turn) { + chat.job = undefined; + chat.jobOutput = undefined; + chat.jobFailures = undefined; + chat.jobCalls = undefined; + chat.turn = undefined; + chat.busy = false; + } + } + }; +} diff --git a/compose.yaml b/compose.yaml index 4a574cee..caa6711a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,9 @@ services: # the preview env file, so preview-overridable values stay late-bound. AGENT: ${AGENT} BACKGROUND_JOBS: ${BACKGROUND_JOBS} + CONVERSATION_PLAN: ${CONVERSATION_PLAN} + JEV_MODEL: ${JEV_MODEL} + JEV_API_KEY: ${JEV_API_KEY} APP_ORIGIN: ${APP_ORIGIN} DATABASE_URL: postgresql://chopin:chopin@${SERVICE_NAME_DB:-db}:5432/chopin?sslmode=disable GITHUB_APP_SLUG: ${GITHUB_APP_SLUG} diff --git a/docs/architecture.md b/docs/architecture.md index dd109b3c..1759f412 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -370,10 +370,13 @@ sent to every turn. Question answers and accepted comment threads belong to durable records outside the document. Their `` and `` nodes are readable MDX projections. Domain operations and Planner block tools update or protect the -record and projection together. Browser Yjs validation currently checks the -dialect but does not independently compare those projections with their records, -so a custom client can create an inconsistent projection without changing the -authoritative decision record. +record and projection together. Browser Yjs validation checks the dialect and +compares protected projections before and after the batch. Unchanged projections +may move; creating, removing or altering a Questionnaire or Decision is rejected. +Research references may be added or removed only with durable request authority, +rechecked at the fenced commit. This preserves the starting projections rather +than independently rebuilding every projection from its record on each edit; +records remain authoritative. A relationship points from one of those records to top-level document blocks. Each anchor combines a Yjs relative position with a digest of the canonical block. @@ -447,10 +450,7 @@ or route for a person to approve the draft. See The prototype still has several places where implementation falls short of the intended boundaries above: -- Idle-room eviction removes the registry entry before its asynchronous final - close and checkpoint completes, so a replacement room can briefly overlap. -- Browser CRDT updates do not cross-check record-owned decision projections, and - custom comment locators do not receive all browser-side bounds on the server. +- Custom comment locators do not receive all browser-side bounds on the server. - Implementation run authorization is repository-based rather than bound to the original claimant; any admitted repository writer with the run ID can report its lifecycle. diff --git a/docs/jev-live-browser-test.md b/docs/jev-live-browser-test.md new file mode 100644 index 00000000..d956b547 --- /dev/null +++ b/docs/jev-live-browser-test.md @@ -0,0 +1,60 @@ +# Jev live browser test โ€” 2 October 2026 + +Tested `Maggie/jev-chat-product` at `d75b2998` in the signed-in local browser, +using the real Jev provider (`jev-1.13.0`) and hosted Planner. This was a bounded +single-browser pass, with nine submitted messages and three new decisions in +`MaggieAppleton/elves/silver-storm`. + +## Confirmed flows + +- A monitoring question created an inline decision; Chopin refined its title. + Follow-up Sentry and Better Stack proposals became separate sourced options + while refinement was running. +- A separate alert-delivery question created its own card. Refinement supplied + Slack and Email options without adding them to the monitoring decision. +- Manual option entry added OpenTelemetry with Grafana. A repeated entry was + rejected with โ€œThat option already existsโ€; the selected Sentry draft survived. +- Source navigation returned to the original question with an exact match. +- Review correction attached an unapplied email tradeoff as a reason. The form + reported โ€œAdded to cardโ€, and the resulting prose included that tradeoff. +- Save produced ordinary prose and resolved readers for both decisions. +- Resolved Discard confirmation could be cancelled with Keep it. Reopen restored + all three monitoring options and the previous answer; saving Grafana replaced + the Sentry paragraph rather than appending a contradictory second paragraph. +- A direct `@chopin` request added a three-item Browser test checklist. +- Reload preserved the chosen answers, option history, reviewed reason, checklist + and chat history. The Decisions view showed both resolved records. +- A third temporary naming decision accepted a Smoke test option, then completed + Discard. It left the document and remained attributed as Discarded in history. +- An unsent draft survived Chat โ†’ Document โ†’ Chat in the compact layout. The + test draft was cleared afterward. + +## Findings requiring follow-up + +1. **Decision anchors become incorrect and overlap.** The monitoring reader first + highlighted its actual Sentry paragraph correctly. After reopening/resaving + as Grafana, adding the checklist through the Planner, reloading, and creating + then discarding the naming card, both resolved markers highlighted the three + checklist items. At a 893ร—850 viewport, both markers had the same rectangle: + x=68.5, y=147.164, width=20, height=20. Clicking the Grafana marker opened Slack; + pressing Enter on Grafana opened the correct reader but still highlighted the + checklist. Thus record identity survives, while passage targeting/placement + is incorrect. The exact mutation that first corrupts the anchors is not yet + isolated. +2. **Re-deciding carries an old option's rationale into new prose.** Sentry was + proposed with exception grouping and stack traces as its reason. After choosing + the manually added Grafana option, the paragraph retained that rationale. + The discussion had not supplied it for Grafana. `prosePrompt()` supplies the + previous paragraph during re-deciding; this is a candidate boundary to inspect, + not a confirmed root cause. +3. **Some useful messages are not applied automatically.** The initial combined + alert question/options message was held because its excerpt's new-thread + confidence was 45%; a clearer question succeeded. A later message containing + the email tradeoff and โ€œI prefer Slack for critical errorsโ€ had three excerpts + marked โ€œno useful roleโ€. Review successfully recovered the tradeoff, but the + preference remained unapplied. The held-question form only offers Option, + Reason and Constraint for existing cards; it cannot create the held question. + +The test records and checklist remain in the local document for inspection. +The original database/hosting decision was not changed. These findings are +observations from the live pass, not fixes or claims of complete stress coverage. diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md new file mode 100644 index 00000000..09665a6d --- /dev/null +++ b/docs/jev-product-extraction.md @@ -0,0 +1,173 @@ +# Jev conversation workflow + +Jev lets ordinary Chat discussion develop into sourced decision cards and, after +an explicit Save, document prose. The Planner remains the agent that writes and +refines the document; Jev classifies discussion and proposes structured changes. +This guide describes the integrated feature and its review boundaries. It does +not establish release readiness or replace the validation records below. + +## What changes for users + +- Chat sits to the left of the document, using the existing resize and compact + workspace controls. Direct `@chopin` requests still invoke the Planner. +- Ordinary discussion can create questions, add options and attach reasons, + constraints, support or objections. Source links lead back to the quoted message. +- Decision cards retain shared drafts, stable option identities and participant + attribution. Suggestions and inferred agreement do not save a decision. +- A writer explicitly saves a choice. Scoped Planner work then turns the saved + decision into prose. Reopen preserves the previous answer; Discard records the + lifecycle change without treating it as agreement. +- Evidence and analysis views explain attributed discussion and expose supported + corrections and retries when interpretation misses useful material. +- Research suggestions require human consent. Accepted offers use the existing + durable research workflow and publish an ordinary child document on success. + Child documents do not offer another level of research. + +The interface extends the existing QuestionView, resolved reader and Decisions +list. It does not require a separate Jev card renderer. See +[UI reconciliation](jev-ui-reconciliation.md) for that integration. + +## How discussion reaches the document + +```text +Saved Chat message + โ†’ durable analysis queue + โ†’ Jev classification of bounded, exact source spans + โ†’ policy validates attribution, target and permitted transitions + โ†’ conversation events and pending effects commit + โ†’ effects create/update cards and enqueue scoped Planner work + +Human Save + โ†’ decision record, document projection and pending work commit + โ†’ card action is mirrored into conversation history + โ†’ Planner writes prose for that saved decision generation +``` + +Classification runs outside the document lock. Before committing its result, +processing rechecks the affected conversation and card state. A stale result +cannot silently overwrite an intervening human choice. Failed analysis has an +explicit retry path; uncertain classification can leave a message unapplied. + +The current implementation has separate durable state for message analysis, +pending effects and receipts, card actions awaiting mirroring, and Planner jobs. +These serve different stages of the flow; they are not separate databases. +They live with the conversation events and decision records in the channel's +versioned sidecar. PostgreSQL commits precede publication to connected clients. +See [storage](storage.md) and [architecture](architecture.md). + +## Where to review + +| Responsibility | Entry points | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Jev transport and configuration | [jev.ts](../apps/server/src/conversation-plan/jev.ts), [config.ts](../apps/server/src/config.ts) | +| Source spans, interpretation and policy | [quotes.ts](../apps/server/src/conversation-plan/quotes.ts), [interpret.ts](../apps/server/src/conversation-plan/interpret.ts), [policy.ts](../apps/server/src/conversation-plan/policy.ts) | +| Accepted events, replay and corrections | [domain.ts](../apps/server/src/conversation-plan/domain.ts), [events.ts](../apps/server/src/conversation-plan/events.ts), [state-restoration.ts](../apps/server/src/conversation-plan/state-restoration.ts) | +| Processing, effects and card mirroring | [runtime.ts](../apps/server/src/conversation-plan/runtime.ts), [service.ts](../apps/server/src/conversation-plan/service.ts), [effects.ts](../apps/server/src/conversation-plan/effects.ts), [card-mirror-runner.ts](../apps/server/src/conversation-plan/card-mirror-runner.ts) | +| Decision records and Save | [records.ts](../apps/server/src/questions/records.ts), [service-submit.ts](../apps/server/src/questions/service-submit.ts), [card-actions.ts](../apps/server/src/questions/card-actions.ts) | +| Scoped Planner work | [planner-jobs.ts](../apps/server/src/conversation-plan/planner-jobs.ts), [job-prompts.ts](../apps/server/src/conversation-plan/job-prompts.ts), [job-scope.ts](../apps/server/src/agent/job-scope.ts) | +| Persistence and wire contracts | [plan/service.ts](../apps/server/src/plan/service.ts), [conversation-plan.d.ts](../packages/protocol/conversation-plan.d.ts) | +| Chat and evidence interface | [room-workspace.tsx](../apps/web/src/room-workspace.tsx), [decision-entry.tsx](../apps/web/src/chat/decision-entry.tsx), [evidence-popover.tsx](../apps/web/src/conversation-plan/evidence-popover.tsx), [analysis-overview.tsx](../apps/web/src/conversation-plan/analysis-overview.tsx) | +| Research consent and execution | [processor-research-consent.ts](../apps/server/src/conversation-plan/processor-research-consent.ts), [accepted-research.ts](../apps/server/src/conversation-plan/accepted-research.ts), [research/service.ts](../apps/server/src/research/service.ts) | + +## Boundaries to preserve + +**Records own decisions.** Conversation support, suggestions and classifier +confidence are advisory. Saving requires an authorized human action. Keep stable +option IDs, previous answers and source attribution through refinement, reopen, +discard and retry. Document widgets and prose are projections of those records. + +**Source evidence must remain exact.** Quoted spans use offsets into saved Chat +messages. Generated labels and prose must not masquerade as a participant's +original wording. Changes to parsing or confidence policy need behavioral +coverage across paraphrases, ambiguity, negation and attribution, rather than +only the particular example that motivated a rule. + +**Commit before publishing.** Preserve fenced persistence, stable action IDs, +idempotent replay and rollback after storage failure. A failed Save must retain +its draft for retry. Card mirroring must not allow stale inference to override +an already committed card action. + +**Planner work retains its authorization boundary.** Jobs use the current +process owner and repository authorization, with tools scoped to their heading, +refinement, suggestion or saved-prose task. Restart does not restore credentials +or grant permission to replay interrupted tool execution. See +[authentication](authentication.md) and [hosted agent](hosted-agent.md). + +**Research consent and publication are separate steps.** Preserve the accepted +brief, option snapshot and request identity through retry and recovery. Recheck +authorization before starting work. Publish the complete child and its parent +link atomically; failure must not expose a partial report or duplicate child. +See [background jobs](background-jobs.md). + +**Browser behavior needs browser checks.** Shared drafts, read-only cards, +selection, source navigation, anchors and highlights cross component boundaries. +Use real browser tests for geometry, focus and event ordering. Keep one mounted +owner for the resolved reader and each editor's highlights. + +## Configuration + +`CONVERSATION_PLAN=on` enables processing; the default is off. Startup requires +`JEV_API_KEY` when enabled. `JEV_MODEL` defaults to `jev-latest`; +`JEV_TIMEOUT_MS` defaults to 30000 and accepts integers from 100 to 60000. +Keep the key on the server. `AGENT=off` separately disables hosted Planner turns; +it is not the switch for Jev classification. See [.env.example](../.env.example) +and [self-hosting](self-hosting.md). + +## Validation and known limits + +Validation evidence is tied to the revision and environment that produced it: + +- [Quality handoff](jev-quality-handoff.md) records unit/domain, PostgreSQL, + application-browser and component-browser runs at its stated checkpoint. Its + deterministic provider fixtures exercise real storage, authorization and tools; + they do not establish the semantic quality of live model responses. +- [UI reconciliation](jev-ui-reconciliation.md) records later checks after + integrating main's card, reader and Chat changes. Those results supersede the + earlier UI evidence for the affected surfaces. +- [Live browser test](jev-live-browser-test.md) records a bounded real-provider + pass at `d75b2998`: nine messages and three new decisions. It confirmed useful + flows but also observed incorrect/overlapping decision anchors, rationale from + a previous option carried into new prose, and useful discussion left unapplied. + Later changes fix saved-prose anchor precedence (`9b0ad917`), ground replacement + prose in the current choice and reasons (`7569a727`), and refresh marker geometry + after editor resizing (`0084bc7f`), with regression coverage. The historical + real-provider pass has not been repeated to verify those same scenarios. Useful + discussion can still remain unapplied; deterministic checks do not establish + live semantic quality or broad collaboration stress coverage. + +Multi-step Planner refinement can leave earlier durable tool writes after an +interruption. Explicit retry does not make the entire semantic edit atomic. +Capacity guards do not repair already oversubscribed historical state. + +Editing this guide or passing source checks does not rerun these suites or +resolve the live findings. For a changed implementation, report fresh results +separately and identify skipped layers. The ordinary checks are `bun test`, +`bun run types`, `bun run ci` and `bun run build`; use a disposable PostgreSQL +service for storage tests and `bun run e2e` for application/browser behavior. +The [quality handoff](jev-quality-handoff.md) documents the dedicated contained +Planner runner; [source Playwright config](../e2e/source/playwright.config.ts) +owns actual-component contracts. Keep fake providers and scripted harnesses out +of production startup. + +## Extraction history + +The feature was selectively ported from prototype commit +`446a9779a937fa5be7cd3eb52fd7f3023d691ed2` onto the product branch +`Maggie/jev-chat-product`, while preserving newer main behavior. The +[source inventory](jev-product-source-map.tsv) is the historical ownership map +against ancestor `f9cd64b1eb271c4534e59667d6028f95825153bb`; its 287 path entries +are not a current file inventory, review checklist or instruction to copy whole +files. Some referenced files were subsequently split or removed. + +The complete chronological extraction ledger remains in +[Git history at 27eb3724](https://github.com/githubnext/chopin/blob/27eb3724a6e7ba3020552e7a1fa17f234811941b/docs/jev-product-extraction.md). +It records intermediate failed checks, source-parity constraints and milestones; +those checkpoints should not be presented as current verification results. + +Evaluation datasets, scoring tools, generated reports and the prototype diary +were excluded from the product extraction. Preserve that separation. A test's +origin in an evaluation does not by itself make it disposable: retain the +underlying behavior and regression it protects, while consolidating repeated +fixtures and replacing sentence-specific policy with general behavior where +supported. See the [quality review](jev-quality-review.md) for the later review +scope and confirmed changes. diff --git a/docs/jev-product-source-map.tsv b/docs/jev-product-source-map.tsv new file mode 100644 index 00000000..19787969 --- /dev/null +++ b/docs/jev-product-source-map.tsv @@ -0,0 +1,288 @@ +snapshot_path owner feature disposition +.env.example product configuration selective merge with current main; retain all relevant tests +apps/server/src/agent/decision-prose.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/decision-prose.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/heading-tool.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/heading-tool.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/job-scope.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/job-scope.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/planner.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/planner.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/refine-tool.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/refine-tool.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/revise-open-decision.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/revise-open-decision.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/tools.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/tools.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/chat/address.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/address.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/job.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/notice.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/queue.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/references.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/references.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/service.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/service.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/config.test.ts product configuration selective merge with current main; retain all relevant tests +apps/server/src/config.ts product configuration selective merge with current main; retain all relevant tests +apps/server/src/conversation-plan/cards-domain.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/cards.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/cards.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/corrections.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-provenance-acceptance.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-provenance.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-scoped-endorsement.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-deferral-domain.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-deferral-effects.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-team-deferral.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d19-flow.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/domain.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/domain.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/effects.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/effects.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/events.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/interpret.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/interpret.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jev.test.ts product transport first slice +apps/server/src/conversation-plan/jev.ts product transport first slice +apps/server/src/conversation-plan/job-context.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-context.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-prompts.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-prompts.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jobs.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jobs.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/linked-card-choice.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/pending-withdrawal.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/pipeline.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/planner-jobs.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/planner-jobs.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/policy.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/policy.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/preference.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/preference.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/prose-job.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/prose-job.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/qualified-choice.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/questions.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/questions.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quote-budget.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quotes.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quotes.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-consent.service.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-offer-detection.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-offers.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-task-snapshot.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/review.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/save-command.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/save-command.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/scoped-save-validation.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/scripted-runner.test.ts product planner-regression-harness adapt test-only harness to current Planner +apps/server/src/conversation-plan/scripted-runner.ts product planner-regression-harness adapt test-only harness to current Planner +apps/server/src/conversation-plan/service-opening.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service-opening.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/sources.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/validation.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/jobs/runner.test.ts product integration-review selective merge with current main; retain all relevant tests +apps/server/src/jobs/runner.ts product integration-review selective merge with current main; retain all relevant tests +apps/server/src/main.ts product runtime-wiring selective merge with current main; retain all relevant tests +apps/server/src/mcp/update-lifecycle.test.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/plan/edit.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/persistence.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/planner-jobs.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/projections.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/questionnaires.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/room.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/room.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/service.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/questions.anchors.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions.card-meta.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions.service.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/anchors.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/anchors.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/backfill.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/backfill.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/card-actions.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/card-actions.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/conversation-card.service.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/option-match.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/prose.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/prose.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/question-option-source.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/records.reopen.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/records.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/reopen.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/service.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/store.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/store.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/write-prose.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/write-prose.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/research/service.test.ts product research-offers selective merge with current main; retain all relevant tests +apps/server/src/research/service.ts product research-offers selective merge with current main; retain all relevant tests +apps/server/src/socket/authorization.test.ts product authorization selective merge with current main; retain all relevant tests +apps/server/src/socket/authorization.ts product authorization selective merge with current main; retain all relevant tests +apps/server/src/storage/contract.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/memory/adapter.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/memory/research.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/model.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/port.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/adapter.test.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/adapter.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/migrations.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/research.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/startup.test.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/tasks/plan-graphs.test.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/tasks/plan-graphs.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/testing/plan.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/web/package.json product dependency-metadata selective merge with current main; retain all relevant tests +apps/web/src/chat/chat.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/decision-entry.test.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/decision-entry.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/markdown.test.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/model.test.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/model.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/references.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/research-offer.test.ts product research-offer-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/research-offer.tsx product research-offer-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/scoped-choice-entry.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/transcript.test.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/transcript.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/analysis-overview.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/analysis-overview.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/card-parts.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence-popover.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence-popover.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/links.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/links.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/markers.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/markers.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/source.css product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/source.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/store.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/store.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/design-audit/icons.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/resizable-pane.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/resizable-pane.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/room-workspace.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/theme.css product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/tokens.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace-model.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace-model.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace.tsx product chat-first-layout selective merge with current main; retain all relevant tests +docs/self-hosting.md product configuration selective merge with current main; retain all relevant tests +e2e/chat-references.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-jobs.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-layout.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-prompts.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-runtime.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-stress.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-ui.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/database.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-anchor-stress.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-evidence.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-prose.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/editing.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/github.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-control.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-process.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-wire.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/planner-jobs.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/playwright.config.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-child-documents.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-child-surface.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offer-fixtures.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offers-ui.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offers.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-content.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-decisions.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-workspace.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar-card-states.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar-navigation.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/smoke.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +packages/dialect/src/dialect.test.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/dialect.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/index.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/limits.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/nodes/questionnaire.test.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/nodes/questionnaire.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/validate.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/editor/src/card-meta.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/card-meta.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/comment-geometry.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/comment-geometry.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-format.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-format.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-layer.tsx product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-pin.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-pin.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-placement.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-state.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-state.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-surface.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-surface.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decisions.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decisions.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/evidence-geometry.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/evidence-geometry.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/index.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/marks.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/marks.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/plan-editor.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/provider.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/provider.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/questionnaires.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/questionnaires.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/styles.css product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widget-options.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets-plugin.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/card-gap.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/card-gap.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/decision-deletion.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation-boundary.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/evidence-hover.tsx product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/widgets/index.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/questionnaire.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/questionnaire.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/icons/src/index.ts product card-icons selective merge with current main; retain all relevant tests +packages/icons/src/line.tsx product card-icons selective merge with current main; retain all relevant tests +packages/protocol/chat.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/conversation-plan.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/index.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/plan.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/question.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/question/src/answer.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/draft.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/index.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/limits.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/options.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/options.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/question.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/decision-parts.tsx product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/index.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/question-view.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/question-view.tsx product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/use-questionnaire.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/use-questionnaire.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/schema.ts product decision-definition-draft selective merge with current main; retain all relevant tests +scripts/check-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/conversation-plan-demo.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-core.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-core.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-jev.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-seed.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-core.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-core.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-stress.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/export-card-quality-snapshot.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/export-card-quality-snapshot.ts eval-hub runner-scoring-demo defer; preserve original in archive diff --git a/docs/jev-quality-handoff.md b/docs/jev-quality-handoff.md new file mode 100644 index 00000000..7a2f61b1 --- /dev/null +++ b/docs/jev-quality-handoff.md @@ -0,0 +1,82 @@ +# Jev feature quality handoff + +Branch: `Maggie/jev-chat-product`. Reviewed application and test revision: +`b7b79ef00c2ffe1fe1ea84a9eb7fdcc034d099e6`. The final handoff commit changes +only documentation. + +The subsequent [UI reconciliation](jev-ui-reconciliation.md) integrates newer +main and replaces the competing Jev decision reader with main's components. +Its verification and UI findings supersede this handoff for those surfaces. + +The review integrated main at `bae7818597895841b0adbe9c023c0a57194af96e` +through merge `d22862a6`, then audited the complete feature by subsystem with +independent re-review. [The review ledger](jev-quality-review.md) records ownership +and confirmed fixes. The extraction ledger and source inventory remain historical +references. + +## Result + +The branch retains the conversation/decisions workflow: sourced interpretation, +shared card drafts and choices, explicit Save, scoped Planner work, decided prose, +and consent-based research children. The review fixed stance targeting after +refinement, staged card mutations, bounded execution context and event capacity, +unsupported child research offers, missing live prose-anchor forwarding, and +pending option cancellation/focus handling. Unmounted historical UI was removed. + +Retained browser scenarios now exercise the mounted interface and actual keyed +option protocol. Duplicate research tests were consolidated without dropping +publication, retry, cancellation, or reconnect coverage. + +## Fresh verification on 2 October 2026 + +| Check | Result | +| ----------------------------------------- | --------------------------------------------------- | +| Unit/domain suite | 3,539 passed; 3 PostgreSQL guards skipped; 0 failed | +| Real PostgreSQL contracts/lifecycle | 72 passed; 0 skipped; 0 failed | +| Complete application Chromium suite | 327 passed; 0 retries | +| Contained Planner/research Chromium suite | 76 passed; 0 retries | +| Actual-component Chromium contracts | 98 passed; 0 retries | +| Workspace and E2E types; repository CI | Passed | +| Exact-revision image and client build | Passed | + +The PostgreSQL suite includes three fresh processes proving accepted document, +conversation, and receipts survive; a running job becomes durably interrupted +once. Browser coverage includes successful heading/refine/prose work, actual +inference process restart, two/three-member collaboration, evidence, native +cut/paste anchors, option acknowledgement races, and research recovery. + +The final image is `chopin-jev-quality:66fa`, digest +`sha256:7f57949a757ec8983beb80aee447812819887d5d93c5499e8943b4a3b21dac31`. +The contained report is in +`e2e/test-results/conversation-plan/a879404db95f8b9c0d528641167dc746/`. +Its attestations confirm the source/build stamp, denied egress, successful CLI, +no remaining worker/listeners, preserved artifacts, and both owned containers +removed. Application/component logs and results are retained locally under +`/private/tmp/jev-*-v3*`; unit evidence is `/private/tmp/jev-browser-fixed-unit.log`. +CI retains four existing lint warnings and one design baseline finding. + +To repeat checks, use `bun test --timeout 30000`, `bun run types`, `bun run ci`, +and a disposable database with `TEST_DATABASE_URL` supplied to +`bun test apps/server/src/storage/postgres`. Run the full application suite with +`bun run e2e --workers=2 --retries=0`; run component contracts through +`bun --bun node_modules/@playwright/test/cli.js test --config e2e/source/playwright.config.ts`. +For contained checks, build `e2e/conversation-plan.Dockerfile` with +`CHOPIN_SOURCE_COMMIT` set to the current full commit, then run +`bun scripts/conversation-plan.ts --image --postgres-image postgres:17`. + +## Remaining limits + +- Paid-provider semantic quality remains unverified. Deterministic model fixtures + exercise real authorization, storage, sockets, and Planner execution. +- Multi-step refinement may retain earlier durable tool writes after interruption; + explicit retry is required. The entire semantic edit is not an atomic batch. +- Capacity reservations prevent new oversubscription; they do not repair legacy + persisted histories already oversubscribed by pending actions. +- Main advanced during this review to `3e57e40ea6da4920d8a6f8a8ee645ff6abadc898`. + The subsequent UI reconciliation integrated and checked its Atomic harness + and Chat mention changes. + +Start follow-up work with [architecture](architecture.md), [storage](storage.md), +[hosted Planner](hosted-agent.md), [background jobs](background-jobs.md), and +[authentication](authentication.md). Keep fixes small and preserve the recorded +behavioral checks. diff --git a/docs/jev-quality-review.md b/docs/jev-quality-review.md new file mode 100644 index 00000000..7a593ac7 --- /dev/null +++ b/docs/jev-quality-review.md @@ -0,0 +1,51 @@ +# Jev quality review + +Reviewed the complete feature diff against current main +`bae7818597895841b0adbe9c023c0a57194af96e`, integrated by merge `d22862a6`. +Historical extraction checks are separate from this review's fresh verification. + +## Coverage + +| Reviewer | Areas | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Domain | Conversation state/replay, policy, quotes, corrections, research detection, outbox and card adapters; conversation protocol and related tests | +| Durability | PostgreSQL/memory contracts, document staging/recovery, questions and decision records, lifecycle mirrors | +| Browser | Web/editor/question/dialect UI, evidence and annotations, current mounted components, retained browser scenarios and fixtures | +| Coordinator | Planner jobs/tools, Chat/harness, authorization, research transport, remaining protocol, config/wiring, contained runners and documentation | + +Reviews checked repository standards and extraction intent separately. Changes +received independent re-review; test support under `src` was distinguished from +runtime code. Working path assignments and detailed audit reports were retained +in `/private/tmp/jev-quality-coverage.tsv` and `/private/tmp/jev-*-audit.md`. + +## Confirmed changes + +- Refined option labels now retain stance identity and reach model context; + original quoted evidence remains intact. +- Planner asks and cancellation stage their document/records/maps before commit; + failure preserves the live draft. Cancellation durably mirrors discard. +- Ordinary Planner questionnaires remain available beyond classifier capacity, + using the existing unlinked-card representation. +- Child documents hide unsupported research offers and reject their UI action. +- Removed unmounted DecisionLayer/placement hooks and unused AddOption component; + retained the current ResolvedLayer and useful behavior tests. +- The contained runner selects successful heading/refine/prose, prompt, evidence + and collaborative stress scenarios instead of one reader-denial case. +- Added real PostgreSQL reconstruction across fresh processes: accepted document, + conversation and receipts survive; a running job becomes durably interrupted once. +- Job contexts are pruned by reachable live and committed work, preserving + in-flight acceptance and winning Save claimants. +- The 12-thread window selects recently touched live decisions without changing + small-context order or model confidence thresholds. +- Pending card actions reserve event slots before authority commits; exhaustion + leaves drafts available and returns an explicit failure. +- Forwarded live decided-prose anchors into the editor's questionnaire store; + written prose now receives its marker and hover relationship. +- Pending option requests retain an Escape-capable field. Late responses preserve + cancellation and the user's current focus, including a reopened composer. +- Consolidated duplicate research fixtures while retaining publication, retry, + cancellation, and reconnect coverage. Updated retained browser assertions to + the mounted interface, keyed option protocol, and native text geometry. + +Final check results and remaining limits belong in +[the co-worker handoff](jev-quality-handoff.md). diff --git a/docs/jev-ui-reconciliation.md b/docs/jev-ui-reconciliation.md new file mode 100644 index 00000000..62c239ec --- /dev/null +++ b/docs/jev-ui-reconciliation.md @@ -0,0 +1,59 @@ +# Jev UI reconciliation + +Branch: `Maggie/jev-chat-product`. Completed after the +[feature quality handoff](jev-quality-handoff.md), on 2 October 2026. +Main `3e57e40ea6da4920d8a6f8a8ee645ff6abadc898` is integrated through `8bcdf9da`. + +## Cause and repair + +Main's card redesign (`b44811c1`), presence updates (`7ed5ba76`) and resolved +reader (`c7c57b96`) were already ancestors of the branch. The Jev migration +overrode their composition; the redesign was not missing from the merge. + +| Surface | Migration problem | Reconciled behavior | +| --------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Open card | `4a61031e` put people and source in an `aside` above the header | Main's header owns a trailing source slot and its existing presence component; participants are deduplicated | +| Actions | `a21f05f6` retained both Cancel and Discard | One Discard action, with confirmation and the existing server callback | +| Resolved reader | `242af279` selected a separate renderer whenever Jev metadata existed | Main's compact hover and expanded pinned reader serve both metadata and ordinary decisions | +| Workspace | Main's right-hand Chat survived; the prototype's left-hand Chat was not migrated | Chat sits left using main's existing pane, resize control, persisted width and responsive modes | + +The duplicate `decision-surface.tsx`, its formatting helper, `ChosenList` and +160 lines of competing reader CSS are removed. The shared resolved action footer +supplies Jev's Discard/Reopen confirmation and pending states. The existing +request controller still guards permissions, duplicate requests and stale replies. + +## Remaining differences from main + +- `packages/visuals` is identical to main. The web theme adds Jev source styling + and reverses only the split Chat panel's existing motion direction. +- `QuestionView`, `SidecarCard`, presence faces and the Decisions list remain the + foundation. Jev adds suggestions, previous answers, refining state, sourced + discussion participants and authoritative lifecycle status. +- The resolved reader adds source navigation and lifecycle actions to main's + markup; it retains main's selected-answer check, folded alternatives and + owner/date row. Additional discussion participants remain named in that row. +- Evidence and analysis are Jev-specific surfaces. Their tokens, avatars and + source icon buttons use the shared design system; supporting/opposing people + retain distinct accessible labels and the opposition ring. +- Extra editor CSS handles evidence placement, canonical card gaps, terminal + collapse and a stable marker lane. Main's reader styles are unchanged. + +Reviewed design-contract hashes were renewed for the four edited component +owners. Their caller-prop, finite motion-class and measured-geometry boundaries +retain the same exact cases; no exception was added or broadened. + +## Verification + +- 3,589 unit/domain tests passed; the three PostgreSQL-only guards were skipped. +- 58 affected application Chromium tests passed, covering mentions, references, + decisions, pane resizing, focus, draft retention and responsive workspaces. +- 102 actual-component Chromium tests passed, including Jev reader lifecycle, + source navigation, narrow geometry, shared drafts and stale acknowledgements. +- All 9 evidence browser tests passed again after adopting shared icon buttons. +- Type checks and client build passed. Repository CI preserves its four existing + lint warnings, 221 reviewed design exceptions and existing design baseline. + +The application tests used disposable PostgreSQL services and disabled model +calls. The live development server keeps Jev enabled and is signed in for local +testing. This reconciliation does not change the paid-provider semantic-quality +limit recorded in the earlier handoff. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index d7602dc2..1285d07a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -198,6 +198,9 @@ turns of its job stage, not as a platform-wide budget; see - A stable DNS name with TLS termination and WebSocket proxying. - Outbound HTTPS access to GitHub and the selected harness's model provider (the hosted Copilot service for `copilot-sdk`). +- If `CONVERSATION_PLAN=on`, outbound HTTPS access to TypeSafe's Jev service + (`api.typesafe.ai`) and a server-side `JEV_API_KEY`, separate from the Planner's + harness credentials. - A GitHub App owned by the deployment. - At least one user with repository push or administration access. - For `copilot-sdk`, an active Copilot entitlement for each user who may own a @@ -239,32 +242,47 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| Variable | Default | Meaning | +| ------------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | +| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | +| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | +| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. +Conversation-derived cards default to off. When enabled, interpretation sends +current and recent Chat messages and selected decision context to TypeSafe's Jev +service. It uses `JEV_API_KEY` and `JEV_MODEL` independently of the Planner's +provider and credentials; `AGENT=off` does not disable it. + +The supplied Compose file forwards `CONVERSATION_PLAN`, `JEV_MODEL` and +`JEV_API_KEY` from the deployment environment. It uses the server's default +30-second interpretation timeout. To override `JEV_TIMEOUT_MS`, configure it +explicitly in the service environment; the Compose file does not forward an +unset optional timeout as an empty value. + Generate the encryption key with: ```bash diff --git a/e2e/conversation-plan-heading.e2e.ts b/e2e/conversation-plan-heading.e2e.ts new file mode 100644 index 00000000..1a292e66 --- /dev/null +++ b/e2e/conversation-plan-heading.e2e.ts @@ -0,0 +1,44 @@ +import { content, expect, ready, test } from "./room"; +import { openJevWire, sendChat, wireFrames } from "./jev-wire"; +import { resetPlannerJobs, scriptJob } from "./planner-jobs"; + +test.beforeEach(resetPlannerJobs); +test.afterEach(resetPlannerJobs); + +test("a successful heading job preserves its decision card for both collaborators and reload", async ({ join, room }) => { + await scriptJob("heading", [{ + tool: "draft_heading", + args: { + revision: "$revision", + title: "Pilot release", + goal: "Evaluate a small pilot before a wider release.", + }, + }]); + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await sendChat(ana, "Should we ship a small pilot?"); + await expect.poll(async () => { + let frame = (await wireFrames(ana)).findLast(frame => + frame.kind === "conversation-plan:jobs" || frame.kind === "conversation-plan:snapshot" + ); + return (frame?.jobs as Array<{ kind: string; status: string }> | undefined) + ?.find(job => job.kind === "heading")?.status; + }).toBe("done"); + for (let page of [ana, bo]) { + await expect(content(page).getByRole("heading", { name: "Pilot release", exact: true })) + .toBeVisible(); + await expect(content(page)).toContainText("Evaluate a small pilot before a wider release."); + await expect(page.getByRole("heading", { name: "Should we ship a small pilot?", exact: true })) + .toBeVisible(); + } + await bo.reload(); + await ready(bo); + await expect(content(bo).getByRole("heading", { name: "Pilot release", exact: true })) + .toBeVisible(); + await expect(content(bo).getByRole("heading", { name: "Pilot release", exact: true })) + .toHaveCount(1); + await expect(content(bo)).toContainText("Evaluate a small pilot before a wider release."); + await expect(bo.getByRole("heading", { name: "Should we ship a small pilot?", exact: true })) + .toBeVisible(); +}); diff --git a/e2e/conversation-plan-jobs.e2e.ts b/e2e/conversation-plan-jobs.e2e.ts new file mode 100644 index 00000000..b872f931 --- /dev/null +++ b/e2e/conversation-plan-jobs.e2e.ts @@ -0,0 +1,269 @@ +import { authenticate, content, expect, ready, roomPath, test } from "./room"; +import { openJevWire, sendChat, wireFrames, wireRequest } from "./jev-wire"; +import { releasePlannerJob, resetPlannerJobs, scriptJob } from "./planner-jobs"; + +import type { Page } from "@playwright/test"; + +const QUESTION = "Should we ship a small pilot?"; +const REFINED = "Should we run a limited pilot?"; +const EXISTING_OPTION = "Start with a small pilot."; +const OPTION = "Pilot with one team"; + +function card(page: Page, question = QUESTION) { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: question, exact: true }) }); +} + +function message(page: Page, messageId: string) { + return page.locator(`[data-chat-message-id="${messageId}"]`); +} + +function inspection(page: Page, messageId: string) { + return message(page, messageId).getByRole("button", { name: /Analysis for message/ }); +} + +function jobs(page: Page) { + return page.getByRole("group", { name: "Planner jobs" }); +} + +async function waitForCard(page: Page) { + await expect(card(page)).toBeVisible(); + return card(page); +} + +async function waitForRefine(page: Page, messageId: string, status: string) { + await waitForJob(page, messageId, status); + let marker = inspection(page, messageId); + if (await marker.getAttribute("aria-expanded") !== "true") { + await marker.focus(); + await marker.press("Enter"); + } + await expect(marker).toHaveAttribute("aria-expanded", "true"); + let popoverId = await marker.getAttribute("aria-controls"); + if (!popoverId) throw new Error("analysis marker did not identify its popover"); + await expect(page.locator(`[id="${popoverId}"]`).getByRole("group", { name: "Planner jobs" })) + .toContainText(`refine ยท ${status}`); +} + +async function job(page: Page, messageId: string) { + let frames = await wireFrames(page); + let latest = frames.findLast(frame => + frame.kind === "conversation-plan:jobs" || frame.kind === "conversation-plan:snapshot" + ); + let values = latest?.jobs; + return Array.isArray(values) + ? values.find(item => + item && typeof item === "object" + && (item as { kind?: unknown }).kind === "refine" + && (item as { trigger?: unknown }).trigger === messageId + ) + : undefined; +} + +async function waitForJob(page: Page, messageId: string, status: string) { + await expect.poll(async () => + (await job(page, messageId) as { status?: string } | undefined)?.status + ) + .toBe(status); + return (await job(page, messageId)) as { id: string; status: string }; +} + +async function releaseHeldRefine(page: Page, messageId: string) { + await releasePlannerJob("refine"); + await expect.poll(async () => + (await job(page, messageId) as { status?: string } | undefined)?.status + ) + .toMatch(/^(done|failed|skipped)$/); +} + +async function expectPopoverInChat(page: Page) { + let [popover, chat, composer] = await Promise.all([ + page.getByLabel("Message analysis").boundingBox(), + page.getByRole("complementary", { name: "Chat" }).boundingBox(), + page.locator(".chat-composer").boundingBox(), + ]); + expect(popover).not.toBeNull(); + expect(chat).not.toBeNull(); + expect(composer).not.toBeNull(); + expect(popover!.x).toBeGreaterThanOrEqual(chat!.x); + expect(popover!.x + popover!.width).toBeLessThanOrEqual(chat!.x + chat!.width); + expect(popover!.y + popover!.height).toBeLessThanOrEqual(composer!.y); +} + +async function headerAction(page: Page, action: "Archive" | "Restore") { + await page.getByRole("banner").getByRole("button", { name: /^Actions for / }).click(); + await page.getByRole("menuitem", { name: action, exact: true }).click(); +} + +async function refineScript(options: { hold?: boolean; target?: string } = {}) { + await scriptJob("refine", [{ + tool: "refine_decision", + args: { + revision: "$revision", + id: options.target ?? "$target", + title: REFINED, + add_options: [{ label: OPTION, rationale: "It keeps the pilot small and concrete." }], + }, + }], { hold: options.hold }); +} + +test.beforeEach(async () => { + await resetPlannerJobs(); +}); + +test.afterEach(async () => { + await resetPlannerJobs(); +}); + +test("both collaborators see a held refining card before the completed job writes its activity", async ({ join, room }) => { + await refineScript({ hold: true }); + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + let messageId: string | undefined; + try { + messageId = await sendChat(ana, QUESTION); + + for (let page of [ana, bo]) { + let decision = await waitForCard(page); + let status = decision.getByRole("status"); + await expect(status).toBeVisible(); + await expect(status).toHaveText("Chopin is refiningโ€ฆ"); + await expect(decision.locator('[data-refining="true"]')).toHaveCount(1); + } + await waitForRefine(ana, messageId, "running"); + await waitForRefine(bo, messageId, "running"); + await sendChat(ana, EXISTING_OPTION); + let existing = card(ana).getByRole("radio", { name: EXISTING_OPTION, exact: true }); + await card(ana).getByText(EXISTING_OPTION, { exact: true }).click(); + await expect(existing).toBeChecked(); + await expect(card(bo).getByRole("radio", { name: EXISTING_OPTION, exact: true })) + .toBeChecked(); + + await releaseHeldRefine(ana, messageId); + for (let page of [ana, bo]) { + let decision = card(page, REFINED); + await expect(decision).toBeVisible(); + await expect(decision.getByRole("radio", { name: EXISTING_OPTION, exact: true })) + .toBeChecked(); + await expect(decision.getByRole("radio", { name: OPTION, exact: true })).toBeVisible(); + await expect(decision.getByText("Chopin is refiningโ€ฆ", { exact: true })).toHaveCount(0); + await expect(page.getByText(`Chopin refined ${REFINED}`, { exact: true })).toBeVisible(); + } + await waitForRefine(ana, messageId, "done"); + await waitForRefine(bo, messageId, "done"); + } finally { + if (messageId) await releaseHeldRefine(ana, messageId); + } +}); + +test("an unscripted refine failure remains inspectable while its card accepts a selection", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let messageId = await sendChat(page, QUESTION); + let decision = await waitForCard(page); + await sendChat(page, EXISTING_OPTION); + await expect(decision.getByRole("radio", { name: EXISTING_OPTION, exact: true })) + .toBeVisible(); + await decision.getByText(EXISTING_OPTION, { exact: true }).click(); + await expect(decision.getByRole("radio", { name: EXISTING_OPTION, exact: true })) + .toBeChecked(); + + await waitForRefine(page, messageId, "failed"); + await expect(jobs(page)).toContainText(/ENOENT.*refine\.json/); +}); + +test("failed jobs survive reload, keep diagnostics in place, and retry with the corrected script", async ({ join, room }) => { + await refineScript({ target: "not-this-card" }); + let page = await join("ana"); + await openJevWire(page, room); + let messageId = await sendChat(page, QUESTION); + await waitForCard(page); + let chatMessage = message(page, messageId); + let transcript = chatMessage.locator("xpath=ancestor::*[@data-focus-boundary][1]"); + let before = await chatMessage.boundingBox(); + let scrollTop = await transcript.evaluate(element => element.scrollTop); + await waitForRefine(page, messageId, "failed"); + let after = await chatMessage.boundingBox(); + expect(before).not.toBeNull(); + expect(after).not.toBeNull(); + expect(Math.abs(after!.y - before!.y)).toBeLessThanOrEqual(1); + expect(await transcript.evaluate(element => element.scrollTop)).toBe(scrollTop); + await expectPopoverInChat(page); + await expect(jobs(page).getByRole("button", { name: "Retry refine job", exact: true })) + .toBeVisible(); + + await page.reload(); + await ready(page); + await openJevWire(page, room); + await waitForRefine(page, messageId, "failed"); + await waitForJob(page, messageId, "failed"); + await refineScript(); + await jobs(page).getByRole("button", { name: "Retry refine job", exact: true }).click(); + await expect(jobs(page)).toContainText("refine ยท done"); + await expect(card(page, REFINED)).toBeVisible(); +}); + +test("an archived held job becomes interrupted and retries only after the document is restored", async ({ join, room }) => { + await refineScript({ hold: true }); + let page = await join("ana"); + await openJevWire(page, room); + let messageId: string | undefined; + try { + messageId = await sendChat(page, QUESTION); + await waitForCard(page); + await waitForRefine(page, messageId, "running"); + + await headerAction(page, "Archive"); + await expect(content(page)).toHaveAttribute("contenteditable", "false"); + await headerAction(page, "Restore"); + await expect(content(page)).toHaveAttribute("contenteditable", "true"); + await page.reload(); + await ready(page); + await openJevWire(page, room); + await waitForRefine(page, messageId, "failed"); + await waitForJob(page, messageId, "failed"); + await expect(jobs(page)).toContainText("interrupted"); + await expect(page.getByText(`Chopin refined ${REFINED}`, { exact: true })).toHaveCount(0); + + await refineScript(); + await jobs(page).getByRole("button", { name: "Retry refine job", exact: true }).click(); + await expect(jobs(page)).toContainText("refine ยท done"); + await expect(card(page, REFINED)).toBeVisible(); + } finally { + if (messageId) await releaseHeldRefine(page, messageId); + } +}); + +test("read-only collaborators cannot retry a failed Planner job", async ({ baseURL, browser, join, room }) => { + await refineScript({ target: "not-this-card" }); + let writer = await join("ana"); + await openJevWire(writer, room); + let messageId = await sendChat(writer, QUESTION); + await waitForCard(writer); + await waitForRefine(writer, messageId, "failed"); + + let context = await browser.newContext({ baseURL }); + try { + let reader = await context.newPage(); + await authenticate(reader, "readonly", baseURL!); + await reader.goto(roomPath(room)); + await expect(content(reader)).toHaveAttribute("contenteditable", "false"); + await openJevWire(reader, room); + await waitForRefine(reader, messageId, "failed"); + await expect(jobs(reader).getByRole("button", { name: "Retry refine job", exact: true })) + .toHaveCount(0); + let failed = await waitForJob(reader, messageId, "failed"); + let frame = await wireRequest(reader, { + kind: "conversation-plan:retry-job", + jobId: failed.id, + }); + expect(frame).toMatchObject({ + kind: "session:error", + message: expect.stringMatching(/write access/), + }); + } finally { + await context.close(); + } +}); diff --git a/e2e/conversation-plan-layout.e2e.ts b/e2e/conversation-plan-layout.e2e.ts new file mode 100644 index 00000000..b9879b3b --- /dev/null +++ b/e2e/conversation-plan-layout.e2e.ts @@ -0,0 +1,89 @@ +import { expect, ready, test } from "./room"; +import { expectNoHorizontalOverflow } from "./responsive"; + +import type { Locator, Page } from "@playwright/test"; + +function box(target: Locator) { + return target.evaluate(element => element.getBoundingClientRect().toJSON() as DOMRect); +} + +function chatPane(page: Page) { + return page.getByRole("complementary", { includeHidden: true, name: "Chat" }); +} + +test("desktop Chat retains its width and stays beside the document as the window grows", async ({ join, page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await join("ana"); + let chat = chatPane(page); + let document = page.locator("main"); + let initialWidth = (await box(chat)).width; + expect(initialWidth).toBeCloseTo(364, 0); + + for (let width of [1280, 1440]) { + await page.setViewportSize({ width, height: 800 }); + await expect.poll(async () => (await box(chat)).width).toBeCloseTo(initialWidth, 0); + let [chatBox, documentBox, frameBox] = await Promise.all([ + box(chat), + box(document), + box(page.locator(".workspace-frame")), + ]); + expect(chatBox.x).toBeCloseTo(frameBox.x, 0); + expect(chatBox.x + chatBox.width).toBeLessThanOrEqual(documentBox.x + 1); + } + + await expect(chat).toBeVisible(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible(); + await expect(page.locator('[aria-label="editable markdown"]')).toBeEditable(); + await expectNoHorizontalOverflow(page); +}); + +test("the Chat edge grows right, keeps its left-side reopen control, and remembers its width", async ({ join, page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await join("ana"); + let chat = chatPane(page); + let handle = page.getByRole("separator", { name: "Resize chat" }); + let initial = await box(chat); + let separator = await box(handle); + let dragX = separator.x + separator.width / 2; + let dragY = separator.y + separator.height / 2; + + await page.mouse.move(dragX, dragY); + await page.mouse.down(); + await page.mouse.move(dragX + 20, dragY, { + steps: 4, + }); + await page.mouse.up(); + await expect.poll(async () => (await box(chat)).width).toBeGreaterThan(initial.width + 10); + expect((await box(chat)).x).toBeCloseTo(initial.x, 0); + let pointerWidth = (await box(chat)).width; + + await handle.press("ArrowRight"); + await expect.poll(async () => (await box(chat)).width).toBeGreaterThan(pointerWidth); + let rememberedWidth = (await box(chat)).width; + expect(rememberedWidth).toBeGreaterThanOrEqual(304); + expect(rememberedWidth).toBeLessThanOrEqual(400); + let frame = await box(page.locator(".workspace-frame")); + + await page.getByRole("button", { name: "Hide chat pane" }).click(); + await expect(chat).toBeHidden(); + let opener = page.getByRole("button", { name: "Show chat pane" }); + let openerBox = await box(opener); + expect(openerBox.x + openerBox.width).toBeLessThanOrEqual(frame.x + frame.width); + expect(openerBox.x - frame.x).toBeLessThan(32); + let controlsBox = await box(page.getByRole("group", { name: "Document view" })); + expect(openerBox.x + openerBox.width).toBeLessThanOrEqual(controlsBox.x); + await opener.click(); + await expect(chat).toBeVisible(); + await expect.poll(async () => (await box(chat)).width).toBeCloseTo(rememberedWidth, 0); + + await page.getByRole("button", { name: "Collapse Projects sidebar" }).click(); + await expect.poll(async () => (await box(chat)).width).toBeCloseTo(rememberedWidth, 0); + await expectNoHorizontalOverflow(page); + + await page.reload(); + await ready(page); + await expect.poll(async () => (await box(chat)).width).toBeCloseTo(rememberedWidth, 0); + await page.setViewportSize({ width: 1280, height: 900 }); + await expect.poll(async () => (await box(chat)).width).toBeCloseTo(rememberedWidth, 0); + await expectNoHorizontalOverflow(page); +}); diff --git a/e2e/conversation-plan-prompts.e2e.ts b/e2e/conversation-plan-prompts.e2e.ts new file mode 100644 index 00000000..bddd30c3 --- /dev/null +++ b/e2e/conversation-plan-prompts.e2e.ts @@ -0,0 +1,513 @@ +import { authenticate, content, expect, roomPath, test } from "./room"; +import { openJevWire, sendChat, wireFrames, wireRequest, wireState } from "./jev-wire"; + +import type { Page } from "@playwright/test"; + +const QUESTION = "Should we ship a small pilot?"; +const OPTION = "Start with a small pilot."; +const FIGMA_REPLY = "Sounds good to me. What about agent access? Copilot? BYO API keys?"; +const THREE_CONTRIBUTIONS = + "Sounds good to me. I am concerned that a small pilot will exclude keyboard-only users. What about agent access?"; + +async function waitForEvent(page: Page, type: string, count = 1) { + await expect.poll(async () => + (await wireState(page))?.events.filter(event => event.type === type).length + ).toBe(count); + return (await wireState(page))!; +} + +async function eventsForMessage(page: Page, messageId: string) { + await expect.poll(async () => + (await wireState(page))?.analysis.find(analysis => analysis.messageId === messageId)?.status + ).toBe("applied"); + let state = (await wireState(page))!; + let analysis = state.analysis.find(item => item.messageId === messageId)!; + return state.events.filter(event => analysis.eventIds.includes(event.id)); +} + +function card(page: Page) { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: QUESTION }) }); +} + +function prompt(page: Page) { + return page.getByRole("group", { name: `Decision prompt: ${QUESTION}` }); +} + +async function expectRetiredPrompts(page: Page, summary: string) { + let prompts = page.locator("[data-decision-prompt]"); + await expect(prompts.first()).toBeVisible(); + await expect.poll(async () => { + let summaries = await prompts.allTextContents(); + return summaries.length > 0 && summaries.every(text => text.includes(summary)); + }).toBe(true); + await expect(prompts.getByRole("button", { name: "Save decision", exact: true })).toHaveCount(0); +} + +function scopedPrompt(page: Page) { + return page.getByRole("group", { name: "Scoped choice: Lexical for this spike" }); +} + +function cardById(page: Page, id: string) { + return page.locator( + `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${id}"]`, + ); +} + +async function scopedChoiceReady(ana: Page, bo: Page, room: string) { + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Which editor should we use?"); + await waitForEvent(ana, "thread.opened"); + await waitForEvent(ana, "card.linked"); + await sendChat(bo, "Lexical"); + await waitForEvent(ana, "option.added"); + await sendChat(bo, "Monaco"); + await waitForEvent(ana, "option.added", 2); + await expect.poll(async () => + (await wireState(ana))?.threads[0]?.contributions.filter( + item => item.kind === "option", + ).length + ).toBe(2); + return (await wireState(ana))!.threads[0]!.questionnaireId!; +} + +async function settled(ana: Page, bo: Page, room: string) { + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, QUESTION); + await waitForEvent(ana, "thread.opened"); + await sendChat(bo, OPTION); + await waitForEvent(ana, "option.added"); + await sendChat(bo, "Let's just go with a small pilot."); + await waitForEvent(ana, "settle.suggested"); +} + +test("one member's choice posts a live Save decision prompt for everyone", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + let suggested = await waitForEvent(ana, "settle.suggested"); + expect(suggested.events.some(event => event.type === "decision.recorded")).toBe(false); + await expect(prompt(ana)).toBeVisible(); + await expect(prompt(bo)).toBeVisible(); + await expect(prompt(ana)).toContainText(`Suggested: ${OPTION}`); + await expect(prompt(ana).locator("img")).toHaveCount(0); + + await prompt(ana).getByRole("button", { name: "Save decision" }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + origin: "human", + actor: { kind: "member", handle: "ana" }, + }); + for (let page of [ana, bo]) { + await expectRetiredPrompts(page, `Decided: ${OPTION} ยท ana`); + } +}); + +test("one person's scoped choice saves through the authenticated wire and survives reload", async ({ baseURL, browser, join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + let cardId = await scopedChoiceReady(ana, bo, room); + let messageId = await sendChat(ana, "I'd pick Lexical for the spike;"); + await waitForEvent(ana, "scoped-choice.proposed"); + let messageEvents = await eventsForMessage(ana, messageId); + let proposal = messageEvents.find(event => event.type === "scoped-choice.proposed"); + if (proposal?.type !== "scoped-choice.proposed") throw new Error("scoped proposal is missing"); + expect(proposal).toMatchObject({ + type: "scoped-choice.proposed", + cardId, + label: "Lexical", + scope: "spike", + source: { messageId, author: { kind: "member", handle: "ana" } }, + }); + expect( + messageEvents.some(event => + event.type === "option.added" || event.type === "settle.suggested" + || event.type === "decision.recorded" + ), + ).toBe(false); + let proposalId = proposal.id; + let notice = scopedPrompt(ana); + await expect(notice).toBeVisible(); + await expect(notice).toContainText("Save Lexical for this spike?"); + await expect(notice).toContainText("Ana"); + await expect(notice).toContainText("I'd pick Lexical for the spike;"); + await expect(notice.getByRole("button", { name: "Save for this spike" })).toBeEnabled(); + await expect(cardById(ana, cardId)).toBeVisible(); + let pending = (await wireState(ana))!.threads[0]!; + expect(pending.status).toBe("exploring"); + expect(pending.decision).toBeUndefined(); + expect(pending.pendingScopedChoice).toMatchObject({ proposalId, optionId: proposal.optionId }); + + let readerContext = await browser.newContext({ baseURL }); + try { + let reader = await readerContext.newPage(); + await authenticate(reader, "readonly", baseURL!); + await reader.goto(roomPath(room)); + await expect(content(reader)).toHaveAttribute("contenteditable", "false"); + await openJevWire(reader, room); + await expect(scopedPrompt(reader)).toBeVisible(); + await expect( + scopedPrompt(reader).getByRole("button", { + name: "Save for this spike", + }), + ).toBeDisabled(); + let denied = await wireRequest(reader, { + kind: "conversation-plan:scoped-choice-save", + actionId: crypto.randomUUID(), + threadId: pending.id, + expectedVersion: pending.version, + proposalId, + cardId, + optionId: proposal.optionId, + expectedLabel: "Lexical", + expectedGeneration: 0, + }); + expect(denied.kind).toBe("session:error"); + expect(denied.message).toMatch(/write access/); + } finally { + await readerContext.close(); + } + + await notice.getByRole("button", { name: "Save for this spike" }).click(); + await expect.poll(async () => + (await wireState(ana))?.events.some(event => + event.type === "scoped-choice.saved" && event.proposalId === proposalId + ) + ).toBe(true); + let saved = (await wireState(ana))!; + expect(saved.events.some(event => event.type === "decision.recorded")).toBe(false); + expect(saved.threads[0]?.status).toBe("exploring"); + await expect(cardById(ana, cardId)).toBeVisible(); + + await ana.reload(); + await expect(content(ana)).toHaveAttribute("contenteditable", "true"); + await openJevWire(ana, room); + await expect(scopedPrompt(ana)).toBeVisible(); + await expect(scopedPrompt(ana)).toContainText("Saved for this spike"); + await expect( + scopedPrompt(ana).getByRole("button", { + name: "Saved for this spike", + }), + ).toBeDisabled(); + await expect(cardById(ana, cardId)).toBeVisible(); + let restored = (await wireState(ana))!; + expect(restored.events.some(event => event.type === "decision.recorded")).toBe(false); + expect(restored.threads[0]?.status).toBe("exploring"); + await expect.poll(async () => { + let frame = (await wireFrames(ana)).findLast(item => item.kind === "question:metas") as + | { cards?: Array<{ id: string; meta: { status: string; history: unknown[] } }> } + | undefined; + return frame?.cards?.find(item => item.id === cardId)?.meta; + }).toMatchObject({ status: "open", history: [] }); +}); + +test("a second member refreshes the scoped choice notice with both exact sources", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await scopedChoiceReady(ana, bo, room); + await sendChat(ana, "I'd pick Lexical for the spike;"); + let proposed = await waitForEvent(ana, "scoped-choice.proposed"); + let proposal = proposed.events.find(event => event.type === "scoped-choice.proposed"); + if (proposal?.type !== "scoped-choice.proposed") throw new Error("scoped proposal is missing"); + await expect(scopedPrompt(ana)).toBeVisible(); + + let agreementMessage = await sendChat( + bo, + "yep, Lexical for the spike. not a final library call yet.", + ); + await waitForEvent(ana, "scoped-choice.agreed"); + let agreementEvents = await eventsForMessage(ana, agreementMessage); + let agreement = agreementEvents.find(event => event.type === "scoped-choice.agreed"); + if (agreement?.type !== "scoped-choice.agreed") throw new Error("scoped agreement is missing"); + expect(agreement).toMatchObject({ + type: "scoped-choice.agreed", + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: "Lexical", + scope: "spike", + source: { messageId: agreementMessage, author: { kind: "member", handle: "bo" } }, + }); + expect( + agreementEvents.some(event => + event.type === "settle.agreed" || event.type === "decision.recorded" + ), + ).toBe(false); + await expect(scopedPrompt(ana)).toHaveCount(1); + let refreshed = scopedPrompt(ana); + await expect(refreshed).toContainText("Ana"); + await expect(refreshed).toContainText("I'd pick Lexical for the spike;"); + await expect(refreshed).toContainText("Bo"); + await expect(refreshed).toContainText("yep, Lexical for the spike."); + await expect(refreshed.getByRole("button", { name: "Save for this spike" })).toBeEnabled(); + let noticeFrame = (await wireFrames(ana)).find(frame => + frame.kind === "chat:message" && JSON.stringify(frame).includes(agreement.id) + ); + expect(noticeFrame).toMatchObject({ + entry: { + decision: { + proposalId: proposal.id, + triggerEventId: agreement.id, + sources: [ + { + messageId: proposal.source.messageId, + author: { kind: "member", handle: "ana" }, + quote: "I'd pick Lexical for the spike;", + start: 0, + end: 31, + role: "support", + }, + { + messageId: agreementMessage, + author: { kind: "member", handle: "bo" }, + quote: "yep, Lexical for the spike.", + start: 0, + end: 27, + role: "support", + }, + ], + }, + }, + }); + let sources = (noticeFrame as unknown as { + entry: { decision: { sources: Array<{ quote: string; start: number; end: number }> } }; + }) + .entry.decision.sources; + let sourceTexts = [ + "I'd pick Lexical for the spike;", + "yep, Lexical for the spike. not a final library call yet.", + ]; + for (let [index, source] of sources.entries()) { + expect(sourceTexts[index]!.slice(source.start, source.end)).toBe(source.quote); + } +}); + +test("the Figma mixed reply settles the pending option and opens its sourced question", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + + let messageId = await sendChat(ana, FIGMA_REPLY); + let events = await eventsForMessage(ana, messageId); + expect(events.map(event => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + "thread.opened", + ]); + expect(events.map(event => "source" in event ? event.source?.quote : undefined)).toEqual([ + "Sounds good to me.", + "Sounds good to me.", + "What about agent access?", + ]); + expect(events.some(event => event.type === "decision.recorded")).toBe(false); + await expect(prompt(ana)).toBeVisible(); + + await prompt(ana).getByRole("button", { name: "Save decision" }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + origin: "human", + actor: { kind: "member", handle: "ana" }, + }); +}); + +test("one mixed reply keeps agreement, concern, and question sources separate", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + + let messageId = await sendChat(ana, THREE_CONTRIBUTIONS); + let events = await eventsForMessage(ana, messageId); + expect(events.map(event => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + "stance.changed", + "thread.opened", + ]); + expect(events.map(event => "source" in event ? event.source?.quote : undefined)).toEqual([ + "Sounds good to me.", + "Sounds good to me.", + "I am concerned that a small pilot will exclude keyboard-only users.", + "What about agent access?", + ]); + expect(events.some(event => event.type === "decision.recorded")).toBe(false); + await expect(prompt(ana)).toBeVisible(); +}); + +test("a late retraction beyond the ownership context limit has no classifier effect", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + let message = `${"Sounds good to me. ".padEnd(4001, "x")}Actually, no.`; + + let messageId = await sendChat(ana, message); + await expect.poll(async () => + (await wireState(ana))?.analysis.find(analysis => analysis.messageId === messageId)?.status + ).toBe("unlinked"); + let state = (await wireState(ana))!; + let analysis = state.analysis.find(item => item.messageId === messageId)!; + expect(analysis).toMatchObject({ policyGate: "message exceeds ownership context", passes: [] }); + expect(analysis.eventIds).toEqual([]); + await expect(prompt(ana)).toHaveCount(1); +}); + +test("repeated agreement does not duplicate a live prompt", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + await sendChat(ana, "Sounds good to me."); + await waitForEvent(ana, "settle.agreed"); + await expect(prompt(ana)).toBeVisible(); + + let repeated = await sendChat(ana, "Sounds good to me."); + await expect.poll(async () => + (await wireState(ana))?.analysis.find(item => item.messageId === repeated)?.status + ).toBe("applied"); + await expect.poll(async () => { + let state = await wireState(ana); + let analysis = state?.analysis.find(item => item.messageId === repeated); + return analysis?.eventIds.some(id => + state?.events.some(event => event.id === id && event.type === "settle.agreed") + ) ?? false; + }).toBe(true); + let repeatedState = (await wireState(ana))!; + expect(repeatedState.events.filter(event => event.type === "settle.agreed")).toHaveLength(2); + await expect(prompt(ana)).toHaveCount(1); + await expect( + ana.locator("[data-decision-prompt]").getByRole("button", { + name: "Save decision", + exact: true, + }), + ).toHaveCount(1); + await expect(prompt(ana)).toBeVisible(); +}); + +test("a remote human card selection overrides the live prompt", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + await sendChat(ana, "Sounds good to me."); + await waitForEvent(ana, "settle.agreed"); + await expect(prompt(ana)).toBeVisible(); + let decision = card(bo); + await decision.getByRole("button", { name: "Add an option", exact: true }).click(); + await decision.getByRole("textbox", { name: "New option" }).fill("Ship to everyone"); + await bo.keyboard.press("Enter"); + await decision.getByText("Ship to everyone", { exact: true }).click(); + await expect(decision.getByRole("radio", { name: "Ship to everyone" })).toBeChecked(); + + await expect(prompt(ana)).toContainText("Selected: Ship to everyone"); + await expect(prompt(ana)).not.toContainText(`Suggested: ${OPTION}`); + await expect.poll(async () => + (await wireState(ana))?.threads[0]?.contributions.find(item => + item.kind === "option" && item.text === "Ship to everyone" + )?.id + ).toEqual(expect.any(String)); + let selectedOption = + (await wireState(ana))!.threads[0]!.contributions.find(item => + item.kind === "option" && item.text === "Ship to everyone" + )!.id; + await prompt(ana).getByRole("button", { name: "Save decision" }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + actor: { kind: "member", handle: "ana" }, + optionId: selectedOption, + origin: "human", + }); + for (let page of [ana, bo]) { + await expectRetiredPrompts(page, "Decided: Ship to everyone ยท ana"); + } +}); + +test("Open in plan focuses the card and Discard collapses the prompt", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + await sendChat(ana, "Sounds good to me."); + await waitForEvent(ana, "settle.agreed"); + await prompt(ana).getByRole("button", { name: "Open in plan" }).click(); + let decision = card(ana); + await expect(decision).toBeFocused(); + await decision.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(decision.getByText("Discard this decision?", { exact: true })).toBeVisible(); + await decision.getByRole("button", { name: "Discard", exact: true }).click(); + await expectRetiredPrompts(ana, "Discarded"); +}); + +test("the proposer's own assent does not post another prompt", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + let assent = await sendChat(bo, "Sounds good to me."); + await expect.poll(async () => + (await wireState(ana))?.analysis.find(item => item.messageId === assent)?.status + ).toBe("applied"); + let state = (await wireState(ana))!; + let analysis = state.analysis.find(item => item.messageId === assent)!; + expect( + analysis.eventIds.some(id => + state.events.some(event => event.id === id && event.type === "settle.agreed") + ), + ).toBe(false); + await expect(ana.locator("[data-decision-prompt]")).toHaveCount(1); +}); + +test("an old prompt retires after Save and Reopen before a new suggestion", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + await sendChat(ana, "Sounds good to me."); + await waitForEvent(ana, "settle.agreed"); + await prompt(ana).getByRole("button", { name: "Save decision" }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + let cardId = decided.threads[0]!.questionnaireId!; + await expectRetiredPrompts(ana, `Decided: ${OPTION} ยท ana`); + let retiredCount = await ana.locator("[data-decision-prompt]").count(); + + await ana.getByRole("button", { name: /^Decisions/ }).click(); + await ana.getByRole("button", { name: "1 resolved" }).click(); + let resolved = ana.locator( + `[data-document-view="decisions"] article[data-plan-sidecar-questionnaire="${cardId}"]`, + ); + await resolved.getByRole("button", { name: "Reopen" }).click(); + await waitForEvent(ana, "decision.reopened"); + await expectRetiredPrompts(ana, "Reopened"); + + await sendChat(bo, "Let's just go with a small pilot."); + await waitForEvent(ana, "settle.suggested", 2); + let prompts = ana.locator("[data-decision-prompt]"); + let retired = prompts.filter({ hasText: "Reopened" }); + await expect(prompts).toHaveCount(retiredCount + 1); + await expect(retired).toHaveCount(retiredCount); + await expect(retired.getByRole("button", { name: "Save decision" })).toHaveCount(0); + await expect(prompt(ana)).toHaveCount(1); + await expect(prompt(ana)).toBeVisible(); + await ana.reload(); + await expect(prompts).toHaveCount(retiredCount + 1); + await expect(retired).toHaveCount(retiredCount); + await expect(retired.getByRole("button", { name: "Save decision" })).toHaveCount(0); + await expect(prompt(ana)).toHaveCount(1); + await expect(prompt(ana)).toBeVisible(); +}); + +test("a read-only prompt keeps Save disabled while Open in plan stays available", async ({ baseURL, browser, join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await settled(ana, bo, room); + await sendChat(ana, "Sounds good to me."); + await waitForEvent(ana, "settle.agreed"); + let context = await browser.newContext({ baseURL }); + try { + let reader = await context.newPage(); + await authenticate(reader, "readonly", baseURL!); + await reader.goto(roomPath(room)); + await expect(content(reader)).toHaveAttribute("contenteditable", "false"); + await expect(prompt(reader)).toBeVisible(); + await expect(prompt(reader).getByRole("button", { name: "Save decision" })).toBeDisabled(); + await prompt(reader).getByRole("button", { name: "Open in plan" }).click(); + await expect(card(reader)).toBeFocused(); + } finally { + await context.close(); + } +}); diff --git a/e2e/conversation-plan-runtime.e2e.ts b/e2e/conversation-plan-runtime.e2e.ts new file mode 100644 index 00000000..9b34e6ce --- /dev/null +++ b/e2e/conversation-plan-runtime.e2e.ts @@ -0,0 +1,407 @@ +import { createChannel } from "./database"; +import { releaseJev, resetJevControl } from "./jev-control"; +import { RESTART_URL, startJevProcess, stopJevProcess } from "./jev-process"; +import { openJevWire, sendChat, wireFrames, wireRequest, wireState } from "./jev-wire"; +import { authenticate, content, expect, ready, roomPath, test } from "./room"; + +import type { ConversationPlan } from "../packages/protocol/index"; +import type { Page } from "@playwright/test"; + +async function stateWith(page: Page, eventType: ConversationPlan.Event["type"], count = 1) { + await expect.poll(async () => + (await wireState(page))?.events.filter(event => event.type === eventType).length + ).toBe(count); + return (await wireState(page))!; +} + +async function sharedState( + ana: Page, + bo: Page, + eventType: ConversationPlan.Event["type"], +) { + let first = await stateWith(ana, eventType); + await expect.poll(async () => (await wireState(bo))?.revision).toBe(first.revision); + let second = await stateWith(bo, eventType); + expect(second).toEqual(first); + return first; +} + +function expectSource( + state: ConversationPlan.State, + type: ConversationPlan.Event["type"], + messageId: string, + handle: string, + quote: string, +) { + expect(state.events.find(event => event.type === type)).toMatchObject({ + source: { messageId, author: { kind: "member", handle }, quote }, + }); +} + +test("two members share sourced threads and decide on the card", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + + let questionId = await sendChat(ana, "Should we ship a small pilot?"); + await stateWith(ana, "thread.opened"); + let opened = await sharedState(ana, bo, "card.linked"); + let thread = opened.threads[0]!; + expect(thread.questionSources[0]).toMatchObject({ + messageId: questionId, + author: { kind: "member", handle: "ana" }, + quote: "Should we ship a small pilot?", + }); + + let optionId = await sendChat(bo, "Start with a small pilot."); + let withOption = await sharedState(ana, bo, "option.added"); + let option = withOption.threads[0]!.contributions.find(item => item.kind === "option")!; + expect(option.sources[0]).toMatchObject({ messageId: optionId, quote: option.text }); + + let reasonId = await sendChat(ana, "A small pilot would catch setup problems early."); + let withReason = await sharedState(ana, bo, "reason.added"); + expectSource( + withReason, + "reason.added", + reasonId, + "ana", + "A small pilot would catch setup problems early.", + ); + let constraintId = await sendChat(bo, "Keep the pilot accessible to keyboard-only users."); + let withConstraint = await sharedState(ana, bo, "constraint.added"); + expectSource( + withConstraint, + "constraint.added", + constraintId, + "bo", + "Keep the pilot accessible to keyboard-only users.", + ); + let supportId = await sendChat(ana, "I support the small pilot."); + let supported = await sharedState(ana, bo, "stance.changed"); + expectSource(supported, "stance.changed", supportId, "ana", "I support the small pilot."); + let stance = supported.threads[0]!.stances[0]!; + expect(stance).toMatchObject({ participant: "ana", optionId: option.id, position: "support" }); + + let corrected = await wireRequest(bo, { + kind: "conversation-plan:correct", + actionId: crypto.randomUUID(), + threadId: thread.id, + expectedVersion: supported.threads[0]!.version, + change: { kind: "retarget-stance", stanceId: stance.id }, + actor: { kind: "member", handle: "forged-actor" }, + source: { author: { kind: "member", handle: "forged-source" } }, + }); + expect(corrected.kind).toBe("conversation-plan:correct"); + let afterCorrection = await sharedState(ana, bo, "card.corrected"); + expect(afterCorrection.events.find(event => event.type === "card.corrected")?.actor) + .toEqual({ kind: "member", handle: "bo" }); + expect(afterCorrection.threads[0]!.stances[0]).toMatchObject({ + participant: "ana", + correctedBy: "bo", + }); + expect(afterCorrection.threads[0]!.stances[0]!.optionId).toBeUndefined(); + + let proposalId = await sendChat(bo, "Let's just go with a small pilot."); + let suggested = await sharedState(ana, bo, "settle.suggested"); + expectSource( + suggested, + "settle.suggested", + proposalId, + "bo", + "Let's just go with a small pilot.", + ); + expect(suggested.threads[0]!.pendingSettle?.optionId).toBe(option.id); + expect(suggested.threads[0]!.status).not.toBe("decided"); + + await bo.getByRole("group", { name: "Document view" }) + .getByRole("button", { name: /^Decisions/ }).click(); + let card = bo.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]') + .filter({ + has: bo.getByRole("heading", { name: "Should we ship a small pilot?" }), + }); + await card.getByRole("button", { name: "Save", exact: true }).click(); + let decided = await sharedState(ana, bo, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + origin: "human", + actor: { kind: "member", handle: "bo" }, + optionId: option.id, + }); + expect(decided.threads[0]!.status).toBe("decided"); + let objectionId = await sendChat( + ana, + "I object: a small pilot will exclude keyboard-only users.", + ); + let challenged = await sharedState(ana, bo, "candidate.proposed"); + expectSource( + challenged, + "candidate.proposed", + objectionId, + "ana", + "I object: a small pilot will exclude keyboard-only users.", + ); + expect(challenged.threads[0]!.status).toBe("decided"); + expect(challenged.threads[0]!.candidates[0]).toMatchObject({ kind: "reopening" }); + let reopeningId = await sendChat(bo, "Let's revisit the small pilot decision."); + await expect.poll(async () => + (await wireState(bo))?.analysis.find(item => item.messageId === reopeningId)?.status + ).toBe("applied"); + let afterReopening = (await wireState(bo))!; + expect(afterReopening.events.filter(event => event.type === "decision.reopened")).toHaveLength(0); + expect(afterReopening.threads[0]!.status).toBe("decided"); + let eventCount = afterReopening.events.length; + let ambiguousId = await sendChat(ana, "Maybe that?"); + await expect.poll(async () => + (await wireState(bo))?.analysis.find(item => item.messageId === ambiguousId)?.status + ).toBe("unlinked"); + expect((await wireState(bo))!.events).toHaveLength(eventCount); +}); + +test("a delayed inference leaves chat live and preserves a concurrent wording edit", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Should we ship a small pilot?"); + await stateWith(ana, "thread.opened"); + let initial = await stateWith(ana, "card.linked"); + let thread = initial.threads[0]!; + let delayed = await sendChat(bo, "Should we test with one team first?"); + let later = await sendChat(ana, "Thanks, I'll review the draft."); + await expect.poll(async () => + (await wireFrames(bo)).some(frame => + frame.kind === "chat:message" + && (frame.entry as { id?: string } | undefined)?.id === later + ) + ).toBe(true); + let waiting = (await wireState(ana))!; + expect(waiting.events.map(event => event.type)).toEqual(["thread.opened", "card.linked"]); + expect(waiting.queue.map(item => item.messageId)).toEqual([delayed, later]); + expect(waiting.analysis.some(item => item.messageId === later)).toBe(false); + let response = await wireRequest(ana, { + kind: "conversation-plan:correct", + actionId: crypto.randomUUID(), + threadId: thread.id, + expectedVersion: thread.version, + change: { kind: "edit", field: "question", text: "Should we ship a limited pilot?" }, + }); + expect(response.kind).toBe("conversation-plan:correct"); + await releaseJev("delayed-question"); + await expect.poll(async () => + (await wireState(bo))?.analysis.find(item => item.messageId === delayed)?.status + ).toBe("applied"); + await expect.poll(async () => + (await wireState(bo))?.analysis.find(item => item.messageId === later)?.status + ).toBe("unlinked"); + let final = (await wireState(bo))!; + expect(final.threads.find(item => item.id === thread.id)?.question) + .toBe("Should we ship a limited pilot?"); + expect(final.events.filter(event => event.type === "thread.opened")).toHaveLength(2); + expect(final.events.filter(event => event.type === "card.corrected")).toHaveLength(1); +}); + +test("failure retries once; refresh and restricted sockets preserve state", async ({ baseURL, browser, join, room }) => { + let ana = await join("ana"); + await openJevWire(ana, room); + let priorMessageId = await sendChat(ana, "Should we ship a small pilot?"); + await stateWith(ana, "thread.opened"); + let prior = await stateWith(ana, "card.linked"); + let priorThread = prior.threads[0]!; + expectSource(prior, "thread.opened", priorMessageId, "ana", "Should we ship a small pilot?"); + let messageId = await sendChat(ana, "Could we test with one team first?"); + await expect.poll(async () => + (await wireState(ana))?.analysis.find(item => item.messageId === messageId)?.status + ).toBe("failed"); + let failed = (await wireState(ana))!; + expect(failed.events).toEqual(prior.events); + expect(failed.threads).toEqual([priorThread]); + expectSource(failed, "thread.opened", priorMessageId, "ana", "Should we ship a small pilot?"); + expect((await wireFrames(ana)).some(frame => + frame.kind === "chat:message" + && (frame.entry as { id?: string } | undefined)?.id === messageId + )).toBe(true); + let actionId = crypto.randomUUID(); + let retry = await wireRequest(ana, { kind: "conversation-plan:retry", actionId, messageId }); + expect(retry).toMatchObject({ kind: "conversation-plan:retry", messageId }); + await stateWith(ana, "thread.opened", 2); + let recovered = await stateWith(ana, "card.linked", 2); + expect(recovered.threads.find(item => item.id === priorThread.id)).toEqual(priorThread); + expectSource(recovered, "thread.opened", priorMessageId, "ana", "Should we ship a small pilot?"); + expect(recovered.events.filter(event => event.type === "thread.opened")[1]).toMatchObject({ + source: { + messageId, + author: { kind: "member", handle: "ana" }, + quote: "Could we test with one team first?", + }, + }); + let stable = { events: recovered.events, threads: recovered.threads }; + let repeated = await wireRequest(ana, { kind: "conversation-plan:retry", actionId, messageId }); + expect(repeated.kind).toBe("conversation-plan:retry"); + expect((await wireState(ana))!.events).toEqual(stable.events); + expect((await wireState(ana))!.threads).toEqual(stable.threads); + + await ana.reload(); + await ready(ana); + await openJevWire(ana, room); + let restored = (await wireState(ana))!; + expect(restored.events).toEqual(stable.events); + expect(restored.threads).toEqual(stable.threads); + expect(restored.analysis.find(item => item.messageId === messageId)?.status).toBe("applied"); + expect((await wireFrames(ana)).find(frame => frame.kind === "chat:history")?.entries) + .toEqual(expect.arrayContaining([expect.objectContaining({ id: messageId })])); + let thread = restored.threads.find(item => item.id === priorThread.id)!; + + let readonlyContext = await browser.newContext({ baseURL }); + let readonly = await readonlyContext.newPage(); + await authenticate(readonly, "readonly", baseURL!); + await readonly.goto(roomPath(room)); + await expect(content(readonly)).toHaveAttribute("contenteditable", "false"); + await openJevWire(readonly, room); + expect((await wireState(readonly))!.events).toEqual(stable.events); + expect((await wireState(readonly))!.threads).toEqual(stable.threads); + expect((await wireFrames(readonly)).find(frame => frame.kind === "session:hello")?.canEdit) + .toBe(false); + for ( + let frame of [ + { + kind: "conversation-plan:correct", + actionId: crypto.randomUUID(), + threadId: thread.id, + expectedVersion: thread.version, + change: { kind: "edit", field: "question", text: "No" }, + }, + { kind: "conversation-plan:retry", actionId: crypto.randomUUID(), messageId }, + ] + ) { + let response = await wireRequest(readonly, frame); + expect(response.kind).toBe("session:error"); + expect(response.message).toMatch(/write access/); + } + await readonlyContext.close(); + + let archived = await ana.request.post(`/api/channels/${room}/archive`, { + headers: { origin: baseURL! }, + }); + expect(archived.status()).toBe(200); + await expect.poll(async () => + (await wireFrames(ana)).some(frame => + frame.kind === "session:access" && frame.canEdit === false + ) + ).toBe(true); + await ana.reload(); + await openJevWire(ana, room); + expect((await wireState(ana))!.events).toEqual(stable.events); + expect((await wireState(ana))!.threads).toEqual(stable.threads); + expect((await wireFrames(ana)).find(frame => frame.kind === "session:hello")?.archivedAt) + .toEqual(expect.any(String)); + for ( + let frame of [ + { + kind: "conversation-plan:correct", + actionId: crypto.randomUUID(), + threadId: thread.id, + expectedVersion: thread.version, + change: { kind: "edit", field: "question", text: "No" }, + }, + { kind: "conversation-plan:retry", actionId: crypto.randomUUID(), messageId }, + ] + ) { + let response = await wireRequest(ana, frame); + expect(response.kind).toBe("session:error"); + expect(response.message).toMatch(/write access/); + } + expect((await wireState(ana))!.events).toEqual(stable.events); + expect((await wireState(ana))!.threads).toEqual(stable.threads); +}); + +test("restoring ordinary and Planner history does not enqueue inference", async ({ join, room, seed }) => { + let humanId = crypto.randomUUID(); + let plannerId = crypto.randomUUID(); + await seed("# Historical conversation\n", { + transcript: [ + { + id: humanId, + author: { kind: "member", handle: "ana" }, + text: "Should we ship a small pilot?", + ts: Date.now(), + }, + { + id: plannerId, + author: { kind: "agent" }, + text: "Start with a small pilot.", + ts: Date.now() + 1, + }, + ], + }); + let page = await join("ana"); + await openJevWire(page, room); + expect((await wireRequest(page, { kind: "session:ping" })).kind).toBe("session:ping"); + let initial = (await wireState(page))!; + expect(initial.events).toHaveLength(0); + expect(initial.queue).toHaveLength(0); + expect(initial.analysis).toHaveLength(0); + expect((await wireFrames(page)).find(frame => frame.kind === "chat:history")?.entries) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: humanId, + author: { kind: "member", handle: "ana" }, + text: "Should we ship a small pilot?", + }), + expect.objectContaining({ + id: plannerId, + author: { kind: "agent" }, + text: "Start with a small pilot.", + }), + ])); + await page.reload(); + await openJevWire(page, room); + expect((await wireRequest(page, { kind: "session:ping" })).kind).toBe("session:ping"); + expect((await wireState(page))!.events).toHaveLength(0); + expect((await wireState(page))!.queue).toHaveLength(0); + expect((await wireState(page))!.analysis).toHaveLength(0); +}); + +test("pending inference resumes after an actual server process restart", async ({ browser }) => { + await resetJevControl(); + let room = crypto.randomUUID(); + await createChannel(8789, room); + let first = await startJevProcess(); + let second: typeof first | undefined; + let context = await browser.newContext({ baseURL: RESTART_URL }); + let replacement: typeof context | undefined; + try { + let page = await context.newPage(); + await authenticate(page, "ana", RESTART_URL); + await page.goto(roomPath(room)); + await ready(page); + await openJevWire(page, room); + let messageId = await sendChat(page, "Should we test with one team first?"); + await expect.poll(async () => + (await wireState(page))?.queue.find(item => item.messageId === messageId)?.status + ).toBe("pending"); + expect((await wireState(page))!.events).toHaveLength(0); + + await stopJevProcess(first); + await context.close(); + second = await startJevProcess(); + replacement = await browser.newContext({ baseURL: RESTART_URL }); + let reopened = await replacement.newPage(); + await authenticate(reopened, "ana", RESTART_URL); + await reopened.goto(roomPath(room)); + await ready(reopened); + await openJevWire(reopened, room); + expect((await wireState(reopened))!.queue).toEqual(expect.arrayContaining([ + expect.objectContaining({ messageId, status: "pending" }), + ])); + expect((await wireState(reopened))!.events).toHaveLength(0); + await releaseJev("delayed-question"); + let completed = await stateWith(reopened, "thread.opened"); + expect(completed.analysis.find(item => item.messageId === messageId)?.status).toBe("applied"); + expect(completed.events.filter(event => event.type === "thread.opened")).toHaveLength(1); + expect(completed.events.filter(event => event.type === "card.linked")).toHaveLength(1); + } finally { + await replacement?.close(); + await context.close(); + await stopJevProcess(second ?? first); + } +}); diff --git a/e2e/conversation-plan-stress.e2e.ts b/e2e/conversation-plan-stress.e2e.ts new file mode 100644 index 00000000..3a7c3077 --- /dev/null +++ b/e2e/conversation-plan-stress.e2e.ts @@ -0,0 +1,475 @@ +import { SQL } from "bun"; + +import { openJevWire, sendChat, wireState } from "./jev-wire"; +import { authenticate, expect, roomPath, test } from "./room"; + +import type { ConversationPlan } from "../packages/protocol/index"; +import type { Browser, Page } from "@playwright/test"; + +const PILOT_QUESTION = "Should we ship a small pilot?"; +const FILES_QUESTION = "Should we store uploaded files in Amazon S3 or on a local disk?"; +const EMAIL_QUESTION = "Which service should send notification emails?"; +const AGENT_ACCESS_QUESTION = "Should our team use an AI coding agent?"; +const ACCESS_QUESTION = "What about agent access?"; +const PILOT_OPTION = "Start with a small pilot."; +const BROAD_OPTION = "Ship to everyone"; +const EMAIL_RECOMMENDATION = "We should use Postmark for notification emails."; +const HELD_AGENT_ACCESS = + "Copilot or bring-your-own API keys would simplify agent access for our team."; + +function card(page: Page, question: string) { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: question, exact: true }) }); +} + +function prompt(page: Page, question = PILOT_QUESTION) { + return page.getByRole("group", { name: `Decision prompt: ${question}` }); +} + +async function expectRetiredPrompts(page: Page, cardId: string, summary: string) { + let prompts = page.locator(`[data-decision-prompt="${cardId}"]`); + await expect(prompts.first()).toBeVisible(); + await expect.poll(async () => { + let summaries = await prompts.allTextContents(); + return summaries.length > 0 && summaries.every(text => text.includes(summary)); + }).toBe(true); + await expect(prompts.getByRole("button", { name: "Save decision", exact: true })).toHaveCount(0); +} + +async function stateWith(page: Page, type: ConversationPlan.Event["type"], count = 1) { + await expect.poll(async () => + (await wireState(page))?.events.filter(event => event.type === type).length + ).toBe(count); + return (await wireState(page))!; +} + +async function readonlyReader(browser: Browser, baseURL: string, room: string) { + let context = await browser.newContext({ baseURL }); + try { + let page = await context.newPage(); + await authenticate(page, "readonly", baseURL); + await page.goto(roomPath(room)); + await expect(page.getByRole("textbox", { name: "editable markdown" })) + .toHaveAttribute("contenteditable", "false"); + return { context, page }; + } catch (error) { + await context.close(); + throw error; + } +} + +async function addOption(page: Page, question: string, option: string) { + let target = card(page, question); + await target.getByRole("button", { name: "Add an option", exact: true }).click(); + await target.getByRole("textbox", { name: "New option" }).fill(option); + await page.keyboard.press("Enter"); + await expect(target.getByRole("radio", { name: option, exact: true })).toBeVisible(); +} + +async function persistedConversation(port: number, room: string) { + let database = new SQL( + process.env.E2E_DATABASE_URL_0 + ?? `postgresql://chopin:chopin@127.0.0.1:${ + port === 8789 ? 5434 : 5433 + }/chopin?sslmode=disable`, + ); + try { + let [row] = await database<{ + sidecar: string | { conversationPlan?: ConversationPlan.State }; + }[]>` + SELECT sidecar FROM channel_state WHERE channel_id = ${room} + `; + let sidecar = typeof row?.sidecar === "string" + ? JSON.parse(row.sidecar) as { conversationPlan?: ConversationPlan.State } + : row?.sidecar; + return sidecar?.conversationPlan; + } finally { + await database.close(); + } +} + +test("three members carry two sourced questions through a suggested and a human choice", async ({ join, room, baseURL }) => { + let ana = await join("ana"); + let bo = await join("bo"); + let cam = await join("cam"); + for (let page of [ana, bo, cam]) await openJevWire(page, room); + + let questionId = await sendChat(ana, PILOT_QUESTION); + await stateWith(cam, "card.linked"); + let optionId = await sendChat(bo, PILOT_OPTION); + await stateWith(ana, "option.added"); + let reasonId = await sendChat(cam, "A small pilot would catch setup problems early."); + await stateWith(bo, "reason.added"); + let constraintId = await sendChat(ana, "Keep the pilot accessible to keyboard-only users."); + await stateWith(cam, "constraint.added"); + let first = (await wireState(ana))!.threads[0]!; + expect(first.questionSources[0]?.messageId).toBe(questionId); + expect(first.contributions.find(item => item.kind === "option")?.sources[0]?.messageId) + .toBe(optionId); + expect(first.contributions.find(item => item.kind === "reason")?.sources[0]?.messageId) + .toBe(reasonId); + expect(first.contributions.find(item => item.kind === "constraint")?.sources[0]?.messageId) + .toBe(constraintId); + + // Keep several ordinary chat turns in flight between the question and suggestion. + let fillerIds: string[] = []; + for (let page of [bo, cam, ana, bo, cam, ana]) { + fillerIds.push(await sendChat(page, "Maybe that?")); + } + await addOption(cam, PILOT_QUESTION, BROAD_OPTION); + await expect.poll(async () => { + let state = await wireState(ana); + return fillerIds.every(id => + state?.analysis.some(item => item.messageId === id && item.status === "unlinked") + ); + }).toBe(true); + + let settleId = await sendChat(bo, "Let's just go with a small pilot."); + let suggested = await stateWith(ana, "settle.suggested"); + let pilot = suggested.threads.find(thread => thread.id === first.id)!; + let suggestedOption = pilot.contributions.find(item => + item.kind === "option" && item.id === pilot.pendingSettle?.optionId + )!; + expect(suggestedOption.text).toBe(PILOT_OPTION); + let humanChoice = BROAD_OPTION; + expect(suggested.events.find(event => event.type === "settle.suggested")?.source?.messageId) + .toBe(settleId); + + let mixedId = await sendChat( + ana, + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + ); + let opened = await stateWith(cam, "thread.opened", 2); + let access = opened.threads.find(thread => thread.question === ACCESS_QUESTION)!; + expect(access.questionSources[0]).toMatchObject({ + messageId: mixedId, + quote: ACCESS_QUESTION, + author: { kind: "member", handle: "ana" }, + }); + for (let page of [ana, bo, cam]) { + await expect(card(page, PILOT_QUESTION)).toBeVisible(); + await expect(card(page, ACCESS_QUESTION)).toBeVisible(); + await expect(prompt(page)).toContainText(`Suggested: ${suggestedOption.text}`); + } + await ana.screenshot({ path: "e2e/test-results/conversation-stress-before.png", fullPage: true }); + + await card(cam, PILOT_QUESTION).getByText(humanChoice, { exact: true }).click(); + await expect(prompt(ana)).toContainText(`Selected: ${humanChoice}`); + await prompt(ana).getByRole("button", { name: "Save decision" }).click(); + let decided = await stateWith(bo, "decision.recorded"); + let chosen = decided.threads.find(thread => thread.id === first.id)!.contributions + .find(item => item.kind === "option" && item.text === humanChoice)!; + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + threadId: first.id, + optionId: chosen.id, + origin: "human", + actor: { kind: "member", handle: "ana" }, + }); + + await addOption(bo, ACCESS_QUESTION, "Use Copilot for the first release"); + let accessCard = card(bo, ACCESS_QUESTION); + await accessCard.getByText("Use Copilot for the first release", { exact: true }).click(); + await accessCard.getByRole("button", { name: "Save", exact: true }).click(); + let final = await stateWith(cam, "decision.recorded", 2); + expect(final.threads.find(thread => thread.id === first.id)?.status).toBe("decided"); + expect(final.threads.find(thread => thread.id === access.id)?.status).toBe("decided"); + expect(final.events.filter(event => event.type === "decision.recorded")).toHaveLength(2); + for (let page of [ana, bo, cam]) { + await expectRetiredPrompts( + page, + first.questionnaireId!, + `Decided: ${humanChoice} ยท ana`, + ); + } + await ana.screenshot({ path: "e2e/test-results/conversation-stress-after.png", fullPage: true }); + + let port = Number(new URL(baseURL!).port); + await expect.poll(async () => (await persistedConversation(port, room))?.revision) + .toBe(final.revision); + let persisted = (await persistedConversation(port, room))!; + expect(persisted.threads.map(thread => [thread.id, thread.status])) + .toEqual(final.threads.map(thread => [thread.id, thread.status])); + expect(persisted.events.filter(event => event.type === "decision.recorded")) + .toEqual(final.events.filter(event => event.type === "decision.recorded")); + await bo.reload(); + await expect(bo.locator(`[data-chat-message-id="${mixedId}"]`)).toBeVisible(); + await expectRetiredPrompts( + bo, + first.questionnaireId!, + `Decided: ${humanChoice} ยท ana`, + ); +}); + +test("a chat burst preserves a new question and a reason on the existing card", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + let cam = await join("cam"); + for (let page of [ana, bo, cam]) await openJevWire(page, room); + await sendChat(ana, PILOT_QUESTION); + await stateWith(ana, "card.linked"); + await sendChat(bo, PILOT_OPTION); + await stateWith(ana, "option.added"); + await sendChat(bo, "Let's just go with a small pilot."); + await stateWith(ana, "settle.suggested"); + + let [mixedId, reasonId] = await Promise.all([ + sendChat(ana, "Sounds good to me. What about agent access? Copilot? BYO API keys?"), + sendChat(cam, "A small pilot would catch setup problems early."), + ]); + await expect.poll(async () => { + let state = await wireState(bo); + return [mixedId, reasonId].every(id => + state?.analysis.some(item => item.messageId === id && item.status === "applied") + ); + }).toBe(true); + let state = (await wireState(bo))!; + expect(state.threads.map(thread => thread.question)) + .toEqual([PILOT_QUESTION, ACCESS_QUESTION]); + expect(state.events.filter(event => event.type === "thread.opened")).toHaveLength(2); + expect(state.events.filter(event => event.type === "option.added")).toHaveLength(1); + expect( + state.threads[0]!.contributions.find(item => + item.kind === "reason" && item.sources.some(source => source.messageId === reasonId) + ), + ).toBeTruthy(); + expect(state.threads[1]!.questionSources[0]?.messageId).toBe(mixedId); + for (let page of [ana, bo, cam]) { + await expect(card(page, PILOT_QUESTION)).toHaveCount(1); + await expect(card(page, ACCESS_QUESTION)).toHaveCount(1); + } +}); + +test("a direct alternatives question keeps both sourced options and allows another card", async ({ join, room }) => { + let ana = await join("ana"); + await openJevWire(ana, room); + + let alternativesId = await sendChat(ana, FILES_QUESTION); + await stateWith(ana, "thread.opened"); + let withOptions = await stateWith(ana, "option.added", 2); + await stateWith(ana, "card.linked"); + let files = withOptions.threads[0]!; + expect(files.question).toBe(FILES_QUESTION); + expect(files.questionSources[0]).toMatchObject({ + messageId: alternativesId, + quote: FILES_QUESTION, + }); + let options = files.contributions.filter(item => item.kind === "option"); + expect(options.map(item => item.text)).toEqual(["Amazon S3", "on a local disk"]); + expect(options.map(item => item.sources[0])).toMatchObject([ + { messageId: alternativesId, quote: "Amazon S3", role: "option" }, + { messageId: alternativesId, quote: "on a local disk", role: "option" }, + ]); + expect(files.decision).toBeUndefined(); + expect(withOptions.events.some(event => event.type === "decision.recorded")).toBe(false); + let filesCard = card(ana, FILES_QUESTION); + await expect(filesCard).toHaveCount(1); + await expect(filesCard.getByRole("radio", { name: "Amazon S3", exact: true })).toBeVisible(); + await expect(filesCard.getByRole("radio", { name: "on a local disk", exact: true })) + .toBeVisible(); + + await sendChat(ana, PILOT_QUESTION); + let withSecondQuestion = await stateWith(ana, "thread.opened", 2); + await stateWith(ana, "card.linked", 2); + expect(withSecondQuestion.threads.map(thread => thread.question)).toEqual([ + FILES_QUESTION, + PILOT_QUESTION, + ]); + expect(withSecondQuestion.events.some(event => event.type === "decision.recorded")).toBe(false); + await expect(filesCard).toHaveCount(1); + await expect(card(ana, PILOT_QUESTION)).toHaveCount(1); +}); + +test("a direct recommendation adds and suggests a sourced choice until a member saves it", async ({ join, room, baseURL }) => { + let ana = await join("ana"); + let bo = await join("bo"); + for (let page of [ana, bo]) await openJevWire(page, room); + + await sendChat(ana, EMAIL_QUESTION); + await stateWith(ana, "card.linked"); + let emailCard = card(ana, EMAIL_QUESTION); + await expect(emailCard).toBeVisible(); + await expect(emailCard.getByRole("radio")).toHaveCount(0); + + let recommendationId = await sendChat(bo, EMAIL_RECOMMENDATION); + let suggested = await stateWith(ana, "settle.suggested"); + await stateWith(ana, "option.added"); + let emailThread = suggested.threads.find(thread => thread.question === EMAIL_QUESTION)!; + let option = emailThread.contributions.find(item => + item.kind === "option" && item.sources.some(source => source.messageId === recommendationId) + )!; + expect(option).toMatchObject({ + text: EMAIL_RECOMMENDATION, + authoring: "quoted", + sources: [{ messageId: recommendationId, quote: EMAIL_RECOMMENDATION, role: "option" }], + }); + expect(emailThread.pendingSettle?.optionId).toBe(option.id); + expect(emailThread.decision).toBeUndefined(); + expect(suggested.events.some(event => event.type === "decision.recorded")).toBe(false); + let recommendedRadio = emailCard.getByRole("radio", { + name: `${EMAIL_RECOMMENDATION} from chat`, + exact: true, + }); + await expect(recommendedRadio).toBeChecked(); + await expect(emailCard.getByText("from chat", { exact: true })).toBeVisible(); + await expect(prompt(ana, EMAIL_QUESTION)).toContainText(`Suggested: ${EMAIL_RECOMMENDATION}`); + await expect(prompt(ana, EMAIL_QUESTION).getByRole("button", { name: "Save decision" })) + .toBeVisible(); + await expect(prompt(bo, EMAIL_QUESTION)).toContainText(`Suggested: ${EMAIL_RECOMMENDATION}`); + await expect(prompt(bo, EMAIL_QUESTION).getByRole("button", { name: "Save decision" })) + .toBeVisible(); + + await prompt(ana, EMAIL_QUESTION).getByRole("button", { name: "Save decision" }).click(); + let decided = await stateWith(bo, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + threadId: emailThread.id, + optionId: option.id, + origin: "human", + actor: { kind: "member", handle: "ana" }, + }); + expect(decided.threads.find(thread => thread.id === emailThread.id)?.decision) + .toMatchObject({ optionId: option.id, actor: { handle: "ana" } }); + + let port = Number(new URL(baseURL!).port); + await expect.poll(async () => (await persistedConversation(port, room))?.revision) + .toBe(decided.revision); + let persisted = (await persistedConversation(port, room))!; + expect(persisted.events.filter(event => event.type === "decision.recorded")) + .toEqual(decided.events.filter(event => event.type === "decision.recorded")); + await bo.reload(); + await expect(bo.locator(`[data-chat-message-id="${recommendationId}"]`)).toBeVisible(); + await expectRetiredPrompts( + bo, + emailThread.questionnaireId!, + `Decided: ${EMAIL_RECOMMENDATION} ยท ana`, + ); +}); + +test("a writer can apply a focused Copilot subspan as an option", async ({ join, room, baseURL, browser }) => { + let ana = await join("ana"); + let bo = await join("bo"); + for (let page of [ana, bo]) await openJevWire(page, room); + + await sendChat(ana, AGENT_ACCESS_QUESTION); + await stateWith(bo, "card.linked"); + let beforeExcerpt = (await wireState(ana))!.threads[0]!; + let messageId = await sendChat(ana, HELD_AGENT_ACCESS); + await expect.poll(async () => { + let analysis = (await wireState(bo))?.analysis.find(item => item.messageId === messageId); + return analysis?.outcomes?.some(item => item.status === "ignored") ?? false; + }).toBe(true); + let readerContext = await readonlyReader(browser, baseURL!, room); + let reader = readerContext.page; + await openJevWire(reader, room); + let readerMessage = reader.locator(`[data-chat-message-id="${messageId}"]`); + await expect(readerMessage.getByRole("button", { name: "Review 1 excerpt" })).toHaveCount(0); + await readerMessage.getByRole("button", { name: /Analysis for message/ }).press("Enter"); + let readerPanel = reader.locator(`[data-analysis-message="${messageId}"]`); + await expect(readerPanel.getByRole("button", { name: "Add to card" })).toBeDisabled(); + await expect(readerPanel.getByRole("button", { name: "Add excerpt" })).toHaveCount(0); + + let writerMessage = ana.locator(`[data-chat-message-id="${messageId}"]`); + await expect(writerMessage.getByRole("button", { name: "Review 1 excerpt" })).toHaveCount(0); + let writerPanel = ana.locator(`[data-analysis-message="${messageId}"]`); + await writerMessage.hover(); + await expect(writerPanel).toHaveCount(0); + await writerMessage.getByRole("button", { name: /Analysis for message/ }).press("Enter"); + await expect(writerPanel).toBeVisible(); + let addToCard = writerPanel.getByRole("button", { name: "Add to card" }); + await addToCard.focus(); + await expect(addToCard).toBeFocused(); + await addToCard.click(); + await expect(writerPanel.getByLabel("Contribution type")).toBeFocused(); + await writerPanel.getByLabel("Contribution type").selectOption("option"); + await writerPanel.getByLabel("Decision card").selectOption({ label: AGENT_ACCESS_QUESTION }); + await writerPanel.getByLabel("Exact text").fill("Copilot"); + let originalPanelStyle = await writerPanel.getAttribute("style"); + await writerPanel.evaluate(node => { + node.style.maxHeight = "none"; + node.style.height = "auto"; + node.style.left = "calc(100vw - 560px)"; + node.style.top = "16px"; + node.style.width = "540px"; + }); + await ana.screenshot({ + path: "docs/prototypes/conversation-plan/decision-cards/screenshots/stress-review-excerpt.png", + }); + await writerPanel.evaluate((node, style) => { + if (style === null) node.removeAttribute("style"); + else node.setAttribute("style", style); + }, originalPanelStyle); + await writerPanel.getByRole("button", { name: "Add excerpt" }).click(); + + let corrected = await stateWith(bo, "option.added"); + let thread = corrected.threads.find(item => item.id === beforeExcerpt.id)!; + let copilotOption = thread.contributions.find(item => + item.kind === "option" + && item.sources.some(source => source.messageId === messageId && source.quote === "Copilot") + )!; + expect(copilotOption).toMatchObject({ + text: "Copilot", + authoring: "quoted", + sources: [{ + messageId, + quote: "Copilot", + start: HELD_AGENT_ACCESS.indexOf("Copilot"), + end: HELD_AGENT_ACCESS.indexOf("Copilot") + "Copilot".length, + role: "option", + }], + }); + await writerPanel.getByRole("button", { name: "Add another excerpt" }).click(); + await writerPanel.getByLabel("Exact text").fill("bring-your-own API keys"); + await writerPanel.getByRole("button", { name: "Add excerpt" }).click(); + let withBothSubspans = await stateWith(bo, "option.added", 2); + let byoOption = withBothSubspans.threads.find(item => item.id === beforeExcerpt.id)! + .contributions.find(item => + item.kind === "option" + && item.sources.some(source => + source.messageId === messageId && source.quote === "bring-your-own API keys" + ) + )!; + expect(byoOption).toMatchObject({ + text: "bring-your-own API keys", + authoring: "quoted", + sources: [{ + messageId, + quote: "bring-your-own API keys", + start: HELD_AGENT_ACCESS.indexOf("bring-your-own API keys"), + end: HELD_AGENT_ACCESS.indexOf("bring-your-own API keys") + "bring-your-own API keys".length, + role: "option", + }], + }); + + await Promise.all([ana.reload(), bo.reload(), reader.reload()]); + for (let page of [ana, bo, reader]) { + await openJevWire(page, room); + await expect.poll(async () => { + let state = await wireState(page); + return state?.threads.find(item => item.id === beforeExcerpt.id)?.contributions.some(item => + item.kind === "option" + && item.sources.some(source => + source.messageId === messageId && source.quote === "bring-your-own API keys" + ) + ) ?? false; + }).toBe(true); + let restored = (await wireState(page))!; + let restoredOption = restored.threads.find(item => item.id === beforeExcerpt.id)! + .contributions.find(item => item.id === byoOption.id)!; + expect(restoredOption.sources[0]).toMatchObject({ + messageId, + quote: "bring-your-own API keys", + start: HELD_AGENT_ACCESS.indexOf("bring-your-own API keys"), + end: HELD_AGENT_ACCESS.indexOf("bring-your-own API keys") + "bring-your-own API keys".length, + role: "option", + }); + let excerptMessage = page.locator(`[data-chat-message-id="${messageId}"]`); + let inspect = excerptMessage.getByRole("button", { name: /Analysis for message:/ }); + await inspect.focus(); + await page.keyboard.press("Enter"); + let restoredPanel = page.locator(`[data-analysis-message="${messageId}"]`); + await expect(restoredPanel.getByText("Added to card", { exact: true })).toBeVisible(); + let addAnother = restoredPanel.getByRole("button", { name: "Add another excerpt" }); + if (page === reader) await expect(addAnother).toBeDisabled(); + else await expect(addAnother).toBeEnabled(); + } + await readerContext.context.close(); +}); diff --git a/e2e/conversation-plan-ui.e2e.ts b/e2e/conversation-plan-ui.e2e.ts new file mode 100644 index 00000000..86fb8db5 --- /dev/null +++ b/e2e/conversation-plan-ui.e2e.ts @@ -0,0 +1,534 @@ +import { openJevWire, sendChat, wireFrames, wireState } from "./jev-wire"; +import { authenticate, content, expect, roomPath, test } from "./room"; +import { seedChildChannel } from "./database"; + +import type { Page } from "@playwright/test"; + +async function waitForEvent(page: Page, type: string, count = 1) { + await expect.poll(async () => + (await wireState(page))?.events.filter(event => event.type === type).length + ).toBe(count); + return (await wireState(page))!; +} + +function decisionCard(page: Page, question = "Should we ship a small pilot?") { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ + has: page.getByRole("heading", { name: question }), + }); +} + +async function expectOpenCardReadOnly(card: ReturnType) { + await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); + await expect(card.getByRole("button", { name: "Discard", exact: true })).toHaveCount(0); + let add = card.getByRole("button", { name: "Add an option", exact: true }); + await expect(add).toBeDisabled(); +} + +test("Chat analysis diagnostics float without moving a bottom message", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let messageId = await sendChat(page, "Should we ship a small pilot?"); + await waitForEvent(page, "thread.opened"); + let message = page.locator(`[data-chat-message-id="${messageId}"]`); + let inspect = message.getByRole("button", { name: /Analysis for message/ }); + let transcript = message.locator("xpath=ancestor::*[@data-focus-boundary][1]"); + await expect(inspect).toBeVisible(); + await message.hover(); + let before = await message.boundingBox(); + let scrollTop = await transcript.evaluate(element => element.scrollTop); + expect(before).not.toBeNull(); + let popover = page.getByLabel("Message analysis"); + await expect(popover).toHaveCount(0); + await inspect.click(); + await expect(popover).toBeVisible(); + let after = await message.boundingBox(); + expect(after).not.toBeNull(); + expect(Math.abs(after!.y - before!.y)).toBeLessThanOrEqual(1); + expect(await transcript.evaluate(element => element.scrollTop)).toBe(scrollTop); + await expect(page.locator("[data-chat-stack] [data-analysis-popover]")).toHaveCount(0); + let popoverBox = await popover.boundingBox(); + let composerBox = await page.locator(".chat-composer").boundingBox(); + expect(popoverBox).not.toBeNull(); + expect(composerBox).not.toBeNull(); + expect(popoverBox!.height).toBeLessThanOrEqual(352); + expect(await popover.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); + expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(composerBox!.y); +}); + +test("another message hover stays quiet and code buttons keep one analysis portal", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let earlierId = await sendChat(page, "Maybe that?"); + await expect.poll(async () => + (await wireState(page))?.analysis.find(item => item.messageId === earlierId)?.status + ).toBe("unlinked"); + let activeId = await sendChat(page, "Should we ship a small pilot?"); + await waitForEvent(page, "thread.opened"); + let earlier = page.locator(`[data-chat-message-id="${earlierId}"]`); + let active = page.locator(`[data-chat-message-id="${activeId}"]`); + let popover = page.locator(`[data-analysis-message="${activeId}"]`); + await active.getByRole("button", { name: /Analysis for message/ }).click(); + await expect(popover).toBeVisible(); + await page.mouse.move(0, 0); + await expect(popover).toBeVisible(); + await expect(page.locator("[data-analysis-popover]")).toHaveCount(1); + await popover.getByText("Model answers and run details").click(); + await expect(popover).toContainText("targeting"); + await popover.getByRole("button", { name: "Close analysis" }).click(); + await earlier.hover(); + await expect(page.locator("[data-analysis-popover]")).toHaveCount(0); + await earlier.getByRole("button", { name: /Analysis for message/ }).click(); + await expect(popover).toHaveCount(0); + await expect(page.locator(`[data-analysis-message="${earlierId}"]`)).toBeVisible(); + await expect(page.locator("[data-analysis-popover]")).toHaveCount(1); + await page.mouse.click(0, 0); + await expect(page.locator("[data-analysis-popover]")).toHaveCount(0); + await earlier.hover(); + await expect(page.locator("[data-analysis-popover]")).toHaveCount(0); +}); + +test("analysis Escape dismisses before compact Chat and narrow split popovers stay in Chat", async ({ join, room }) => { + let page = await join("ana", { viewport: { width: 390, height: 900 } }); + await openJevWire(page, room); + await page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Chat/ }).click(); + let compactMessage = await sendChat(page, "Should we ship a small pilot?"); + await waitForEvent(page, "thread.opened"); + let compactMarker = page.locator(`[data-chat-message-id="${compactMessage}"]`) + .getByRole("button", { name: /Analysis for message/ }); + let analysis = page.getByLabel("Message analysis"); + await compactMarker.focus(); + await expect(analysis).toHaveCount(0); + await compactMarker.press("Enter"); + await expect(analysis).toBeVisible(); + await compactMarker.press("Escape"); + await expect(analysis).toBeHidden(); + await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); + await compactMarker.press("Escape"); + await expect(page.getByRole("complementary", { name: "Chat" })).toBeHidden(); + + await page.setViewportSize({ width: 1100, height: 900 }); + await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); + let splitMessage = await sendChat(page, "Could we use a small pilot?"); + let splitMessageElement = page.locator(`[data-chat-message-id="${splitMessage}"]`); + let splitMarker = splitMessageElement + .getByRole("button", { name: /Analysis for message/ }); + await expect(splitMarker).toBeVisible(); + await page.mouse.move(0, 0); + await splitMessageElement.hover(); + await expect(analysis).toHaveCount(0); + await splitMarker.click(); + await expect(analysis).toBeVisible(); + let [popoverBox, chatBox] = await Promise.all([ + analysis.boundingBox(), + page.getByRole("complementary", { name: "Chat" }).boundingBox(), + ]); + expect(popoverBox).not.toBeNull(); + expect(chatBox).not.toBeNull(); + expect(popoverBox!.x).toBeGreaterThanOrEqual(chatBox!.x); + expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(chatBox!.x + chatBox!.width); +}); + +test("a chat question becomes an inline decision card", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let editor = content(page); + await editor.click(); + await page.evaluate(() => { + (window as typeof window & { __originalEditor?: Element | null }).__originalEditor = document + .querySelector('[role="textbox"][aria-label="editable markdown"]'); + }); + let questionMessage = await sendChat(page, "Should we ship a small pilot?"); + await waitForEvent(page, "thread.opened"); + await expect(editor).toBeFocused(); + expect( + await page.evaluate(() => + document.querySelector('[role="textbox"][aria-label="editable markdown"]') + === (window as typeof window & { __originalEditor?: Element | null }).__originalEditor + ), + ).toBe(true); + let compoundMessage = await sendChat( + page, + "Start with a small pilot. **Keep the pilot accessible to keyboard-only users.**", + ); + let state = await waitForEvent(page, "constraint.added"); + let thread = state.threads[0]!; + let option = thread.contributions.find(item => item.kind === "option")!; + let card = decisionCard(page); + await expect(card).toBeVisible(); + await expect(card.getByRole("radio", { name: "Start with a small pilot." })).toBeVisible(); + await expect(card.locator("fieldset")).toHaveCount(1); + await expect(card.getByRole("list")).toHaveCount(0); + await expect(page.locator(`[data-chat-message-id="${questionMessage}"] [data-message-markers]`)) + .toBeAttached(); + let compound = page.locator(`[data-chat-message-id="${compoundMessage}"]`); + await expect(compound.locator(`[data-card-link="${option.id}"]`)).toHaveCount(0); + let inspect = compound.getByRole("button", { name: /Analysis for message/ }); + await inspect.press("Enter"); + let analysis = page.getByLabel("Message analysis"); + let cardLink = analysis.locator(`[data-card-link="${option.id}"]`); + await expect(cardLink).toHaveText("Proposal"); + await cardLink.click(); + await expect(analysis).toHaveCount(0); + await expect(card).toBeFocused(); + await card.getByRole("button", { name: "Show source in chat" }).click(); + await expect(page.locator(`[data-chat-message-id="${questionMessage}"]`)) + .toHaveAttribute("data-source-exact", "true"); + await expect.poll(() => page.evaluate(() => CSS.highlights.has("conversation-source"))).toBe( + true, + ); + + await inspect.focus(); + await expect(analysis).toHaveCount(0); + await inspect.press("Enter"); + await expect(analysis).toContainText("findings applied"); + await analysis.getByText("Model answers and run details").click(); + await expect(analysis).toContainText("targeting"); + await analysis.getByRole("button", { name: "Close analysis" }).click(); + await expect(analysis).toHaveCount(0); + await expect(inspect).toBeFocused(); + await inspect.press("Space"); + await expect(analysis).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(analysis).toHaveCount(0); + await expect(inspect).toBeFocused(); + await inspect.press("Enter"); + let close = analysis.getByRole("button", { name: "Close analysis" }); + await close.focus(); + await close.press("Tab"); + let keyboardCardLink = analysis.locator(`[data-card-link="${option.id}"]`); + await expect(keyboardCardLink).toBeFocused(); + await keyboardCardLink.press("Enter"); + await expect(analysis).toHaveCount(0); + await expect(card).toBeFocused(); + let unlinkedMessage = await sendChat(page, "Maybe that?"); + await expect.poll(async () => + (await wireState(page))?.analysis.find(item => item.messageId === unlinkedMessage)?.status + ).toBe("unlinked"); + let unlinked = page.locator(`[data-chat-message-id="${unlinkedMessage}"]`); + await expect(unlinked.getByRole("button", { name: "Analysis for message: unlinked" })) + .toBeVisible(); + await expect(unlinked.getByRole("button", { name: "Analysis for message: unlinked" })) + .toHaveAttribute("data-analysis-trigger", "true"); + await expect(page.getByText("Unlinked", { exact: true })).toHaveCount(0); +}); + +test("mounted parent and child transcripts keep their own source ranges", async ({ baseURL, join, room }) => { + let childTitle = `Highlighted child ${room.slice(0, 8)}`; + let child = await seedChildChannel( + Number(new URL(baseURL!).port), + room, + crypto.randomUUID(), + childTitle, + "# Child document\n", + ); + let page = await join("ana"); + await openJevWire(page, room); + let parentMessageId = await sendChat(page, "Should we ship a small pilot?"); + let parentState = await waitForEvent(page, "thread.opened"); + await page.clock.install(); + await page.clock.pauseAt(Date.now() + 100); + let parent = page.locator(`[data-workspace-room="${room}"]`); + await parent.getByRole("button", { name: "Show source in chat" }).click(); + let highlightedRooms = () => + page.evaluate(() => + Array.from(CSS.highlights.get("conversation-source") ?? [], range => { + let start = range.startContainer; + let element = start instanceof Element ? start : start.parentElement; + return element?.closest("[data-workspace-room]")?.getAttribute("data-workspace-room"); + }) + ); + await expect.poll(highlightedRooms).toEqual([room]); + await page.getByRole("complementary", { name: "Projects" }).getByRole("link", { + name: childTitle, + exact: true, + }).click(); + let childWorkspace = page.locator(`[data-workspace-room="${child.id}"]`); + await expect(childWorkspace).toBeVisible(); + await openJevWire(page, child.id); + let childMessageId = await sendChat(page, "Should we ship a small pilot?"); + let childState = await waitForEvent(page, "thread.opened"); + expect(childState.threads[0]?.id).not.toBe(parentState.threads[0]?.id); + await childWorkspace.getByRole("button", { name: "Show source in chat" }).click(); + await expect.poll(highlightedRooms).toEqual([room, child.id]); + await expect(parent.locator(`[data-chat-message-id="${parentMessageId}"]`)) + .toHaveAttribute("data-source-exact", "true"); + await expect(childWorkspace.locator(`[data-chat-message-id="${childMessageId}"]`)) + .toHaveAttribute("data-source-exact", "true"); + await page.getByRole("button", { name: `Close ${childTitle}` }).click(); + await page.clock.runFor(500); + await expect.poll(highlightedRooms).toEqual([room]); + await expect(childWorkspace).toHaveCount(0); + await page.clock.runFor(1_500); + await parent.getByRole("button", { name: "Show source in chat" }).click(); + await page.clock.runFor(1_001); + await expect.poll(highlightedRooms).toEqual([room]); + await page.clock.runFor(2_000); + await expect.poll(highlightedRooms).toEqual([]); + let parentMessage = parent.locator(`[data-chat-message-id="${parentMessageId}"]`); + await expect(parentMessage).not.toHaveAttribute("data-source-exact", "true"); + await expect(parentMessage.locator("[data-source-preview]")).toHaveCount(0); +}); + +test("an inline card arriving keeps the typist's caret", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + let editor = content(ana); + await editor.click(); + await ana.keyboard.type("A local draft survives the incoming decision."); + await sendChat(bo, "Should we ship a small pilot?"); + await waitForEvent(ana, "thread.opened"); + let card = decisionCard(ana); + await expect(card).toBeVisible(); + await expect(editor).toBeFocused(); + await expect(editor).toContainText("A local draft survives the incoming decision."); +}); + +test("a proposal to settle pre-selects the option until a person saves", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Should we ship a small pilot?"); + await waitForEvent(ana, "thread.opened"); + await sendChat(bo, "Start with a small pilot."); + await waitForEvent(ana, "option.added"); + let card = decisionCard(ana); + let option = card.getByRole("radio", { name: "Start with a small pilot." }); + await expect(option).not.toBeChecked(); + + await sendChat(bo, "Let's just go with a small pilot."); + await waitForEvent(ana, "settle.suggested"); + await expect(option).toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toBeVisible(); + await expect(card.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + + await card.getByRole("button", { name: "Save", exact: true }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + expect(decided.events.find(event => event.type === "decision.recorded")).toMatchObject({ + origin: "human", + actor: { kind: "member", handle: "ana" }, + }); +}); + +test("a human choice overrides an advisory chat suggestion", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Should we ship a small pilot?"); + await waitForEvent(ana, "thread.opened"); + await sendChat(bo, "Start with a small pilot."); + let withOption = await waitForEvent(ana, "option.added"); + let humanOptionId = withOption.threads[0]!.contributions.find(item => item.kind === "option")!.id; + let card = decisionCard(ana); + await card.getByRole("button", { name: "Add an option", exact: true }).click(); + await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone"); + await ana.keyboard.press("Enter"); + let suggestion = card.getByRole("radio", { name: "Ship to everyone" }); + await expect(suggestion).toBeVisible(); + await sendChat(bo, "Let's just go with Ship to everyone."); + await waitForEvent(ana, "settle.suggested"); + await expect(suggestion).toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toBeVisible(); + let humanChoice = card.getByRole("radio", { name: "Start with a small pilot." }); + await card.getByText("Start with a small pilot.", { exact: true }).click(); + await expect(humanChoice).toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); + await card.getByRole("button", { name: "Save", exact: true }).click(); + let decided = await waitForEvent(ana, "decision.recorded"); + expect(decided.threads[0]!.decision).toMatchObject({ + optionId: humanOptionId, + actor: { kind: "member", handle: "ana" }, + }); +}); + +test("typing an option before a suggestion keeps the composer intent until Escape", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Should we ship a small pilot?"); + await waitForEvent(ana, "thread.opened"); + await sendChat(bo, "Start with a small pilot."); + await waitForEvent(ana, "option.added"); + let card = decisionCard(ana); + let option = card.getByRole("radio", { name: "Start with a small pilot." }); + await card.getByRole("button", { name: "Add an option", exact: true }).click(); + let field = card.getByRole("textbox", { name: "New option" }); + await field.fill("Ship to everyone"); + await sendChat(bo, "Let's just go with a small pilot."); + await waitForEvent(ana, "settle.suggested"); + await expect(field).toBeFocused(); + await expect(field).toHaveValue("Ship to everyone"); + await expect(option).not.toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); + await field.press("Escape"); + let add = card.getByRole("button", { name: "Add an option", exact: true }); + await expect(add).toBeFocused(); + await expect(option).toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toBeVisible(); +}); + +test("adding an option does not suppress a later chat suggestion", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, "Should we ship a small pilot?"); + await waitForEvent(ana, "thread.opened"); + await sendChat(bo, "Start with a small pilot."); + await waitForEvent(ana, "option.added"); + let card = decisionCard(ana); + await card.getByRole("button", { name: "Add an option", exact: true }).click(); + await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone"); + await ana.keyboard.press("Enter"); + await expect(card.getByRole("radio", { name: "Ship to everyone" })).toBeVisible(); + + await sendChat(bo, "Let's just go with Ship to everyone."); + await waitForEvent(ana, "settle.suggested"); + await expect.poll(async () => { + let meta = (await wireFrames(ana)).findLast(frame => frame.kind === "question:meta")?.meta as + | { suggested?: { optionId: string } } + | undefined; + return meta?.suggested?.optionId; + }).toEqual(expect.any(String)); + let meta = (await wireFrames(ana)).findLast(frame => frame.kind === "question:meta")?.meta as { + suggested: { optionId: string }; + }; + let label = (await wireState(ana))!.threads[0]!.contributions + .find(item => item.id === meta.suggested.optionId)?.text; + expect(label).toBeTruthy(); + await expect(card.getByRole("radio", { name: label! })).toBeChecked(); + await expect(card.getByRole("radio", { name: "Start with a small pilot." })).not.toBeChecked(); + await expect(card.getByText("from chat", { exact: true })).toBeVisible(); +}); + +test("failed analysis retries in Chat while editor selection survives hiding Chat", async ({ join, room, seed }) => { + await seed("# Rich document\n\nA selected sentence.\n"); + let page = await join("ana"); + await openJevWire(page, room); + let failureMessage = await sendChat(page, "Could we test with one team first?"); + await expect.poll(async () => + (await wireState(page))?.analysis.find(item => item.messageId === failureMessage)?.status + ).toBe("failed"); + let message = page.locator(`[data-chat-message-id="${failureMessage}"]`); + let inspect = message.getByRole("button", { name: /Analysis for message/ }); + await message.locator("[data-chat-message-text]").click(); + let analysis = page.getByLabel("Message analysis"); + await expect(analysis).toHaveCount(0); + await inspect.click(); + let close = analysis.getByRole("button", { name: "Close analysis" }); + let retry = analysis.getByRole("button", { name: "Retry analysis" }); + await expect(retry).toBeVisible(); + await expect(close).toBeFocused(); + await close.press("Tab"); + let details = analysis.getByText("Model answers and run details"); + await expect(details).toBeFocused(); + await details.press("Tab"); + await expect(retry).toBeFocused(); + await retry.press("Enter"); + await waitForEvent(page, "thread.opened"); + await expect(analysis).toContainText("finding applied"); + await expect(page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]')) + .toBeVisible(); + + let editor = content(page); + await editor.click(); + await page.keyboard.press("ControlOrMeta+A"); + let selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).toContain("selected sentence"); + await page.getByRole("button", { name: /Hide chat pane/ }).click(); + await page.getByRole("button", { name: /Show chat pane/ }).click(); + await expect(editor).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString() ?? "")) + .toContain("selected sentence"); +}); + +test("a second acknowledged retry uses a fresh action after analysis fails again", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let messageId = await sendChat(page, "Could we retry a flaky question twice?"); + await expect.poll(async () => + (await wireState(page))?.queue.find(item => item.messageId === messageId)?.status + ).toBe("failed"); + let marker = page.locator(`[data-chat-message-id="${messageId}"]`); + await marker.locator("[data-chat-message-text]").click(); + let analysis = page.getByLabel("Message analysis"); + await expect(analysis).toHaveCount(0); + await marker.getByRole("button", { name: /Analysis for message/ }).click(); + await analysis.getByRole("button", { name: "Retry analysis" }).click(); + await expect.poll(async () => + (await wireState(page))?.queue.find(item => item.messageId === messageId) + ).toMatchObject({ status: "failed", attempts: 1 }); + await analysis.getByRole("button", { name: "Retry analysis" }).click(); + await waitForEvent(page, "thread.opened"); + await expect.poll(async () => + (await wireState(page))?.queue.some(item => item.messageId === messageId) + ).toBe(false); +}); + +test("scrolling a pinned message analysis out of Chat dismisses its portal", async ({ join, room }) => { + let page = await join("ana"); + await openJevWire(page, room); + let messageId = await sendChat(page, "Should we ship a small pilot?"); + await waitForEvent(page, "thread.opened"); + let marker = page.locator(`[data-chat-message-id="${messageId}"]`); + await marker.locator("[data-chat-message-text]").click(); + let analysis = page.locator(`[data-analysis-message="${messageId}"]`); + await expect(analysis).toHaveCount(0); + await marker.getByRole("button", { name: /Analysis for message/ }).click(); + await expect(analysis).toBeVisible(); + for (let index = 0; index < 32; index++) await sendChat(page, `Maybe that? ${index}`); + let transcript = marker.locator("xpath=ancestor::*[@data-focus-boundary][1]"); + await transcript.evaluate(element => element.scrollTop = element.scrollHeight); + await expect.poll(() => transcript.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + await expect.poll(() => + marker.evaluate(element => { + let transcript = element.closest("[data-focus-boundary]"); + if (!transcript) return false; + return element.getBoundingClientRect().bottom <= transcript.getBoundingClientRect().top; + }) + ).toBe(true); + await expect(analysis).toBeHidden(); +}); + +test("read-only and archived readers keep cards and diagnostics with disabled controls", async ({ baseURL, browser, join, room }) => { + let writer = await join("ana"); + await openJevWire(writer, room); + let messageId = await sendChat(writer, "Should we ship a small pilot?"); + await waitForEvent(writer, "thread.opened"); + let readonlyContext = await browser.newContext({ baseURL }); + try { + let reader = await readonlyContext.newPage(); + await authenticate(reader, "readonly", baseURL!); + await reader.goto(roomPath(room)); + await expect(content(reader)).toHaveAttribute("contenteditable", "false"); + await expect(decisionCard(reader)).toBeVisible(); + await expectOpenCardReadOnly(decisionCard(reader)); + let marker = reader.locator(`[data-chat-message-id="${messageId}"]`); + await decisionCard(reader).getByRole("button", { name: "Show source in chat" }).click(); + await expect(marker).toHaveAttribute("data-source-exact", "true"); + await marker.locator("[data-chat-message-text]").click(); + let analysis = reader.getByLabel("Message analysis"); + await expect(analysis).toHaveCount(0); + await marker.getByRole("button", { name: /Analysis for message/ }).click(); + await expect(analysis).toContainText("finding applied"); + await analysis.getByText("Model answers and run details").click(); + await expect(analysis).toContainText("triage"); + } finally { + await readonlyContext.close(); + } + let archived = await writer.request.post(`/api/channels/${room}/archive`, { + headers: { origin: baseURL! }, + }); + expect(archived.status()).toBe(200); + await expectOpenCardReadOnly(decisionCard(writer)); + await writer.reload(); + await expect(content(writer)).toHaveAttribute("contenteditable", "false"); + await expect(decisionCard(writer)).toBeVisible(); + await expectOpenCardReadOnly(decisionCard(writer)); +}); diff --git a/e2e/conversation-plan.Dockerfile b/e2e/conversation-plan.Dockerfile new file mode 100644 index 00000000..dbd1a322 --- /dev/null +++ b/e2e/conversation-plan.Dockerfile @@ -0,0 +1,30 @@ +FROM oven/bun:1.4.2 AS bun +FROM mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun +WORKDIR /work +ARG CHOPIN_SOURCE_COMMIT +RUN bun -e 'if (!/^[0-9a-f]{40}$/.test(process.env.CHOPIN_SOURCE_COMMIT || "")) throw new Error("CHOPIN_SOURCE_COMMIT must be 40 lowercase hex characters");' +LABEL org.chopin.source-commit=$CHOPIN_SOURCE_COMMIT +ENV CI=1 PLAYWRIGHT_BROWSERS_PATH=/ms-playwright PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +COPY package.json bun.lock tsconfig.json ./ +COPY packages ./packages +COPY apps/server ./apps/server +COPY apps/web ./apps/web +COPY e2e ./e2e +COPY scripts ./scripts +COPY patches ./patches +RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --registry=https://registry.npmjs.org +RUN bun run build +RUN <<'SH' +bun -e ' +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { sourceHash, treeHash } from "./scripts/conversation-plan-worker.ts"; +let stamp = { commit: process.env.CHOPIN_SOURCE_COMMIT, + lockSha256: createHash("sha256").update(await readFile("/work/bun.lock")).digest("hex"), + sourceSha256: await sourceHash("/work"), + buildSha256: await treeHash("/work", ["apps/web/dist"], new Set()) }; +await writeFile("/work/e2e/.conversation-plan-build.json", JSON.stringify(stamp) + "\n");' +SH +ENTRYPOINT ["/usr/local/bin/bun"] +CMD ["scripts/conversation-plan-worker.ts"] diff --git a/e2e/conversation-plan.Dockerfile.dockerignore b/e2e/conversation-plan.Dockerfile.dockerignore new file mode 100644 index 00000000..4d6fce06 --- /dev/null +++ b/e2e/conversation-plan.Dockerfile.dockerignore @@ -0,0 +1,56 @@ +** +!package.json +!bun.lock +!tsconfig.json +!apps/ +!apps/server/ +!apps/server/** +!apps/web/ +!apps/web/** +!packages/ +!packages/** +!e2e/ +!e2e/** +!scripts/ +!scripts/conversation-plan.ts +!scripts/conversation-plan-worker.ts +!patches/ +!patches/** +**/node_modules +**/dist +**/test-results +**/playwright-report +**/.conversation-plan-build.json +**/.env* +**/.git* +**/.codex +**/.agents +**/.claude +**/.opencode +**/.ssh +**/.aws +**/.npmrc +**/.netrc +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/id_rsa* +**/id_ed25519* +**/credentials +**/credentials.json +**/secrets +**/secrets.json +**/*.log +**/*.tsbuildinfo +**/data +**/artifacts +**/eval +**/evals +**/evaluations +**/corpus +**/datasets +**/.scratch +e2e/design/snapshots +**/dataset +**/evaluation diff --git a/e2e/conversation-plan.playwright.config.ts b/e2e/conversation-plan.playwright.config.ts new file mode 100644 index 00000000..5809d1dd --- /dev/null +++ b/e2e/conversation-plan.playwright.config.ts @@ -0,0 +1,113 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { defineConfig, devices } from "@playwright/test"; + +import { requireScriptedServer, SCRIPTED_HARNESS } from "./harness/scripted-environment"; +import { registerScriptedHarness } from "./harness/scripted-register"; +import { JEV_CONTROL_DIR, resetJevControl } from "./jev-control"; +import { PLANNER_JOBS_DIR, resetPlannerJobs } from "./planner-jobs"; +import { HOST, PLAIN, ROOT } from "./servers"; + +// Loading this explicitly selected config may reset fixtures and start a server. +if (process.env.E2E_CONVERSATION_PLAN !== "1") { + throw new Error("chopin: conversation-plan tests require E2E_CONVERSATION_PLAN=1"); +} + +let database = process.env.E2E_DATABASE_URL_0; +let sessionKey = process.env.SESSION_ENCRYPTION_KEY; +if (!database?.trim()) { + throw new Error("chopin: conversation-plan tests require a supplied E2E_DATABASE_URL_0"); +} +if (!sessionKey?.trim()) { + throw new Error("chopin: conversation-plan tests require a supplied SESSION_ENCRYPTION_KEY"); +} + +let origin = `http://${HOST}:${PLAIN}`; +let serverEnvironment = { + PORT: String(PLAIN), + SERVER_HOST: HOST, + APP_ORIGIN: origin, + AGENT: "on", + CONVERSATION_PLAN: "on", + HARNESS: SCRIPTED_HARNESS, + HARNESS_AUTH: "direct", + MODEL: "e2e-prompt-scripted-model", + JEV_API_KEY: "e2e-jev-only", + JEV_MODEL: "jev-e2e", + TYPESAFE_API_KEY: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + NO_PROXY: "*", + no_proxy: "*", + E2E_CONVERSATION_PLAN: "1", + E2E_PLANNER_JOBS_DIR: PLANNER_JOBS_DIR, + E2E_JEV_CONTROL_DIR: JEV_CONTROL_DIR, + BACKGROUND_JOBS: "off", + WEB_RESEARCH: "off", + STORAGE_DRIVER: "postgres", + DATABASE_URL: database, + GITHUB_APP_SLUG: "chopin-e2e", + GITHUB_APP_CLIENT_ID: "e2e", + GITHUB_APP_CLIENT_SECRET: "e2e", + GITHUB_ALLOWED_USERS: "", + GITHUB_ALLOWED_ORGANIZATIONS: "githubnext", + SESSION_ENCRYPTION_KEY: sessionKey, + DEV_QUESTIONS: "", + DEV_COMMENTS: "", +}; +requireScriptedServer(serverEnvironment, ROOT); +await registerScriptedHarness(process.env, ROOT); + +if (!existsSync(join(ROOT, "apps/web/dist/index.html"))) { + throw new Error("chopin: conversation-plan tests require an existing built client"); +} + +// These checks cannot establish database disposability, client freshness or network containment. +await resetPlannerJobs(); +await resetJevControl(); + +export default defineConfig({ + testDir: ".", + testMatch: [ + join(ROOT, "e2e/conversation-plan-heading.e2e.ts"), + join(ROOT, "e2e/conversation-plan-jobs.e2e.ts"), + join(ROOT, "e2e/conversation-plan-layout.e2e.ts"), + join(ROOT, "e2e/conversation-plan-runtime.e2e.ts"), + join(ROOT, "e2e/conversation-plan-prompts.e2e.ts"), + join(ROOT, "e2e/conversation-plan-stress.e2e.ts"), + join(ROOT, "e2e/conversation-plan-ui.e2e.ts"), + join(ROOT, "e2e/decision-anchor-stress.e2e.ts"), + join(ROOT, "e2e/decision-evidence.e2e.ts"), + join(ROOT, "e2e/decision-prose.e2e.ts"), + join(ROOT, "e2e/research-child-recovery.e2e.ts"), + join(ROOT, "e2e/research-offers.e2e.ts"), + join(ROOT, "e2e/research-offers-ui.e2e.ts"), + ], + outputDir: "test-results/conversation-plan", + fullyParallel: false, + workers: 1, + forbidOnly: true, + retries: 0, + reporter: [["list"]], + expect: { timeout: 10_000 }, + use: { trace: "retain-on-failure" }, + projects: [{ + name: "conversation-plan", + use: { ...devices["Desktop Chrome"], baseURL: origin }, + }], + webServer: { + command: "bun --preload ./e2e/harness/scripted-network-preload.ts" + + " --preload ./e2e/github.ts --preload ./e2e/jev.ts" + + " --preload ./e2e/harness/scripted-preload.ts apps/server/src/main.ts", + cwd: ROOT, + url: `${origin}/`, + env: serverEnvironment, + reuseExistingServer: false, + gracefulShutdown: { signal: "SIGTERM", timeout: 2_000 }, + }, +}); diff --git a/e2e/decision-anchor-stress.e2e.ts b/e2e/decision-anchor-stress.e2e.ts new file mode 100644 index 00000000..459e6a2f --- /dev/null +++ b/e2e/decision-anchor-stress.e2e.ts @@ -0,0 +1,515 @@ +/** Stress the durable prose link while nearby document text changes. */ + +import { content, expect, test, written } from "./room"; +import { openJevWire, sendChat, wireFrames, wireRequest } from "./jev-wire"; +import { resetPlannerJobs, scriptJob } from "./planner-jobs"; +import * as Y from "../apps/server/node_modules/yjs"; +import * as PlanRoom from "../apps/server/src/plan/room"; +import { readSource } from "./database"; + +import type { Plan } from "../packages/protocol/index"; +import type { Page, WebSocketRoute } from "@playwright/test"; + +const QUESTION = "Should we ship a small pilot?"; +const OPTION = "Start with a small pilot."; +const MARKED_PROSE = "We chose the reversible pilot."; +type ClientNode = { + getType(): string; + getChildren(): ClientNode[]; + getDecision?(): { id: string }; + remove(): void; +}; +type EditorInternals = { _nodeMap: Map }; + +function marker(page: Page) { + return page.getByRole("button", { name: `Decision: ${OPTION}`, exact: true }); +} + +function prose(page: Page) { + return content(page).locator("p").filter({ hasText: `We decided: ${OPTION}` }); +} + +function commentDecision(page: Page) { + return page.locator("[data-plan-comment-card]").filter({ hasText: "Keep this choice recorded." }); +} + +function port(page: Page): number { + return Number(new URL(page.url()).port); +} + +async function decide(page: Page, room: string) { + await scriptJob("prose", [{ + tool: "write_decision_prose", + args: { revision: "$revision", id: "$target", text: `We decided: ${OPTION}` }, + }]); + await openJevWire(page, room); + await sendChat(page, QUESTION); + await sendChat(page, OPTION); + let card = page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: QUESTION, exact: true }) }); + await expect(card).toBeVisible(); + await card.getByText(OPTION, { exact: true }).click(); + await card.getByRole("button", { name: "Save", exact: true }).click(); + await expect(prose(page)).toBeVisible(); + await expect(marker(page)).toBeVisible(); +} + +async function latestDocumentSnapshot(page: Page, room: string): Promise { + await openJevWire(page, room); + await expect.poll(async () => (await wireFrames(page)).some(frame => frame.kind === "plan:open")) + .toBe(true); + let frame = (await wireFrames(page)).findLast(item => item.kind === "plan:open"); + expect(frame).toBeTruthy(); + return frame as unknown as Plan.Open.Reply; +} + +function expectLinked(snapshot: Plan.Open.Reply, widget?: string): string { + let linked = snapshot.prose!.find(entry => + (!widget || entry.widget === widget) && entry.anchors.length > 0 + ); + expect(linked).toBeTruthy(); + expect(linked?.orphaned).toBe(false); + expect(snapshot.anchors.some(entry => entry.widget === linked?.widget)).toBe(true); + return linked!.widget; +} + +async function placeCaretAtParagraphEnd(page: Page, textContent: string) { + return content(page).evaluate((root, expectedText) => { + let paragraph = [...root.querySelectorAll(":scope > p")].find( + item => item.textContent === expectedText, + ); + let text = paragraph?.querySelector("[data-lexical-text]")?.firstChild; + if (!(text instanceof Text)) throw new Error("the destination paragraph has no text node"); + let selection = window.getSelection(); + if (!selection) throw new Error("selection unavailable"); + (root as HTMLElement).focus(); + let range = document.createRange(); + range.setStart(text, text.data.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return { + active: document.activeElement === root, + anchor: selection.anchorNode === text, + atEnd: selection.anchorOffset === text.data.length, + paragraph: paragraph?.textContent ?? null, + contentEditable: (root as HTMLElement).contentEditable, + }; + }, textContent); +} + +async function expectResolvedDecision(page: Page, room: string) { + await expect(marker(page)).toBeVisible(); + await page.getByRole("button", { name: /^Decisions/ }).click(); + let history = page.getByRole("button", { name: "1 resolved", exact: true }); + if (await history.getAttribute("aria-expanded") !== "true") await history.click(); + await expect(history).toHaveAttribute("aria-expanded", "true"); + let resolved = page.locator( + '[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]', + ).filter({ has: page.getByText(QUESTION, { exact: true }) }); + await expect(resolved.getByText(QUESTION, { exact: true })).toBeVisible(); + await expect(resolved).toContainText(OPTION); + let widget = await resolved.getAttribute("data-plan-sidecar-questionnaire"); + if (!widget) throw new Error("resolved decision id missing"); + expectLinked(await latestDocumentSnapshot(page, room), widget); + await page.getByRole("button", { name: "Document", exact: true }).click(); +} + +test.beforeEach(async () => { + await resetPlannerJobs(); +}); + +test.afterEach(async () => { + await resetPlannerJobs(); +}); + +test("a custom browser update cannot remove an accepted Decision projection", async ({ join, page, room, seed }) => { + await seed(`${MARKED_PROSE}\n`); + let appFrames: Array> = []; + let browserSocket: WebSocketRoute | undefined; + let resolveOpen: ((frame: Plan.Open.Reply) => void) | undefined; + let resolveReset: (() => void) | undefined; + let waitForPlanOpen = () => new Promise(resolve => resolveOpen = resolve); + let waitForReset = () => new Promise(resolve => resolveReset = resolve); + await page.routeWebSocket("**/ws?**", route => { + let server = route.connectToServer(); + browserSocket = server; + route.onMessage(message => server.send(message)); + server.onMessage(message => { + if (typeof message === "string") { + let frame = JSON.parse(message) as Record; + appFrames.push(frame); + if (frame.kind === "plan:open" && resolveOpen) { + let resolve = resolveOpen; + resolveOpen = undefined; + resolve(frame as unknown as Plan.Open.Reply); + } + if (frame.kind === "plan:reset" && resolveReset) { + let resolve = resolveReset; + resolveReset = undefined; + resolve(); + } + } + route.send(message); + }); + }); + + let editor = await join("ana"); + let other = await join("ben"); + await openJevWire(editor, room); + let started = await wireRequest(editor, { + kind: "comment:start", + blocks: [0], + quote: MARKED_PROSE, + offset: 0, + length: MARKED_PROSE.length, + text: "Keep this choice recorded.", + }); + expect(started.ok).toBe(true); + let threadId = (started.thread as { id: string }).id; + let accepted = await wireRequest(editor, { kind: "comment:accept", id: threadId }); + expect(accepted.ok).toBe(true); + await expect(content(editor)).toContainText(MARKED_PROSE); + await expect(content(other)).toContainText(MARKED_PROSE); + await expect(commentDecision(editor)).toContainText(MARKED_PROSE); + await expect(commentDecision(other)).toContainText(MARKED_PROSE); + await written(editor, room, " { + let root = (client.editor.getEditorState() as unknown as EditorInternals)._nodeMap.get("root"); + decision = root?.getChildren().find(node => + node.getType() === "plan-decision" && node.getDecision?.().id === threadId + ); + if (decision) decisionId = decision.getDecision!().id; + }); + if (!decision) throw new Error("accepted Decision is missing from the opened Yjs state"); + client.editor.update(() => decision!.remove(), { discrete: true }); + await PlanRoom.settle(); + let update = Y.encodeStateAsUpdate(client.doc, before); + expect(PlanRoom.project(client)).not.toContain(`id="${decisionId}"`); + expect(update.byteLength).toBeGreaterThan(3); + client.doc.destroy(); + let rid = crypto.randomUUID(); + let rejectedReset = waitForReset(); + browserSocket?.send(JSON.stringify({ + kind: "plan:update", + ts: 0, + rid, + id: crypto.randomUUID(), + epoch: snapshot.epoch, + update: Buffer.from(update).toString("base64"), + })); + await rejectedReset; + expect(appFrames.some(frame => frame.kind === "plan:ack" && frame.rid === rid)).toBe(false); + expect(appFrames.some(frame => frame.kind === "plan:reset")).toBe(true); + await expect(content(editor)).toContainText(MARKED_PROSE); + await expect(content(other)).toContainText(MARKED_PROSE); + await expect(commentDecision(editor)).toContainText(MARKED_PROSE); + await expect(commentDecision(other)).toContainText(MARKED_PROSE); + await expect.poll(() => readSource(port(editor), room)).toMatch( + new RegExp(`]*id="${decisionId}"`), + ); + await expect.poll(() => readSource(port(editor), room)).toContain(MARKED_PROSE); + + await editor.reload(); + await expect(content(editor)).toHaveAttribute("contenteditable", "true", { timeout: 20_000 }); + await expect(content(editor)).toContainText(MARKED_PROSE); + await expect(commentDecision(editor)).toContainText(MARKED_PROSE); + await openJevWire(editor, room); + let synced = (await wireFrames(editor)).findLast(frame => frame.kind === "comment:sync") as { + threads?: Array<{ id: string; status: string; quote?: string }>; + } | undefined; + expect(synced?.threads).toContainEqual(expect.objectContaining({ + id: threadId, + status: "accepted", + quote: MARKED_PROSE, + })); + await expect(content(other)).toContainText(MARKED_PROSE); + await expect(commentDecision(other)).toContainText(MARKED_PROSE); +}); + +test( + "adjacent edits and a reconnect preserve a resolved decision's prose anchor", + async ({ join, page, room }, testInfo) => { + let sockets: WebSocketRoute[] = []; + await page.routeWebSocket("**/ws?**", route => { + route.connectToServer(); + sockets.push(route); + }); + let editor = await join("ana"); + await decide(editor, room); + let other = await join("ben"); + await expect(marker(other)).toBeVisible(); + let widget = expectLinked(await latestDocumentSnapshot(editor, room)); + + // Create a following line, then use one Backspace to join it to the decision. + let trailing = content(editor).locator(":scope > p").last(); + await trailing.click(); + await editor.keyboard.type("Following detail."); + let following = content(editor).locator("p").filter({ hasText: "Following detail." }); + await expect(following).toHaveText("Following detail."); + await editor.keyboard.press(process.platform === "darwin" ? "Meta+ArrowLeft" : "Home"); + await editor.keyboard.press("Backspace"); + await editor.keyboard.type(" More detail."); + await expect(prose(editor)).toContainText(`We decided: ${OPTION} More detail.`); + await expect(prose(other)).toContainText("More detail."); + await expect(marker(other)).toBeVisible(); + widget = expectLinked(await latestDocumentSnapshot(editor, room), widget); + + // Shift the associated paragraph down by inserting context above it. + let leading = content(editor).locator(":scope > p").first(); + await expect(leading).toBeEmpty(); + await leading.click(); + await editor.keyboard.type("Earlier context."); + let context = content(editor).locator("p").filter({ hasText: "Earlier context." }); + await expect(context).toHaveText("Earlier context."); + let order = await content(editor).locator("p").allTextContents(); + expect(order.findIndex(text => text.includes("Earlier context."))).toBeLessThan( + order.findIndex(text => text.includes(`We decided: ${OPTION}`)), + ); + await expect(marker(editor)).toBeVisible(); + await expect(marker(other)).toBeVisible(); + widget = expectLinked(await latestDocumentSnapshot(editor, room), widget); + + // An edited anchored paragraph should retain the same live relation. + expect(await placeCaretAtParagraphEnd(editor, await prose(editor).innerText())) + .toMatchObject({ active: true, anchor: true, atEnd: true, contentEditable: "true" }); + await editor.keyboard.type(" Still the same decision."); + await expect(prose(editor)).toContainText("Still the same decision."); + await expect(prose(other)).toContainText("Still the same decision."); + widget = expectLinked(await latestDocumentSnapshot(editor, room), widget); + await expectResolvedDecision(editor, room); + + // Drop the UI connection and let the provider reopen the document itself. + await sockets[0]!.close(); + await expect(content(editor)).toHaveAttribute("contenteditable", "false"); + await expect(content(editor)).toHaveAttribute("contenteditable", "true", { timeout: 20_000 }); + expect(await placeCaretAtParagraphEnd(editor, await prose(editor).innerText())) + .toMatchObject({ active: true, anchor: true, atEnd: true, contentEditable: "true" }); + await editor.keyboard.type(" After reconnect."); + await expect(prose(editor)).toContainText("After reconnect."); + await expect(prose(other)).toContainText("After reconnect."); + widget = expectLinked(await latestDocumentSnapshot(editor, room), widget); + + await editor.reload(); + await expect(prose(editor)).toContainText("After reconnect."); + await expect(prose(other)).toContainText("After reconnect."); + await expect(marker(other)).toBeVisible(); + await expectResolvedDecision(editor, room); + expectLinked(await latestDocumentSnapshot(editor, room), widget); + let followingBlock = content(editor).locator("p").filter({ hasText: "Following detail." }); + await followingBlock.click(); + await editor.keyboard.press("End"); + await editor.mouse.move(40, 650); + await editor.screenshot({ path: testInfo.outputPath("decision-anchor-after-edits.png") }); + }, +); + +test( + "cut and paste carries the decision anchor with the moved prose", + async ({ context, join, room }, testInfo) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + let page = await join("ana"); + await decide(page, room); + let widget = expectLinked(await latestDocumentSnapshot(page, room)); + let contextParagraph = content(page).locator(":scope > p").last(); + await contextParagraph.click(); + await page.keyboard.type("Move destination."); + await expect(contextParagraph).toHaveText("Move destination."); + let beforeMove = await content(page).locator(":scope > p").allTextContents(); + expect(beforeMove.findIndex(text => text.includes(`We decided: ${OPTION}`))).toBeLessThan( + beforeMove.findIndex(text => text === "Move destination."), + ); + + await prose(page).selectText(); + await page.keyboard.press("ControlOrMeta+X"); + await expect(content(page)).not.toContainText(`We decided: ${OPTION}`); + let destination = content(page).locator(":scope > p").filter({ + hasText: /^Move destination\.$/, + }); + await expect(destination).toHaveCount(1); + let caret = await placeCaretAtParagraphEnd(page, "Move destination."); + expect(caret).toEqual({ + active: true, + anchor: true, + atEnd: true, + paragraph: "Move destination.", + contentEditable: "true", + }); + await page.keyboard.press("Enter"); + let pasteTarget = destination.locator("xpath=following-sibling::p[1]"); + await expect(pasteTarget).toBeEmpty(); + await expect.poll(async () => + pasteTarget.evaluate(paragraph => { + let selection = window.getSelection(); + let anchor = selection?.anchorNode; + return selection?.isCollapsed === true && !!anchor + && (paragraph === anchor || paragraph.contains(anchor)); + }) + ).toBe(true); + await page.keyboard.press("ControlOrMeta+V"); + let moved = prose(page); + await expect(moved).toHaveText(`We decided: ${OPTION}`); + let afterMove = await content(page).locator(":scope > p").allTextContents(); + expect(afterMove.findIndex(text => text === "Move destination.")).toBeLessThan( + afterMove.findIndex(text => text.includes(`We decided: ${OPTION}`)), + ); + let snapshot = await latestDocumentSnapshot(page, room); + let relationship = snapshot.prose!.find(entry => entry.widget === widget); + expect(relationship).toBeTruthy(); + expect(snapshot.anchors.some(entry => entry.widget === widget)).toBe(true); + console.log("Post-cut/paste authoritative decision prose:", JSON.stringify(relationship)); + await testInfo.attach("decision-prose-anchor-after-cut-paste.json", { + body: JSON.stringify( + { + relationship, + decision: snapshot.anchors.find(entry => entry.widget === widget), + }, + null, + 2, + ), + contentType: "application/json", + }); + await page.getByRole("button", { name: /^Decisions/ }).click(); + await expect(page.getByRole("button", { name: "1 resolved", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Document", exact: true }).click(); + await expect(page.locator("[data-decision-collapsing]")).toHaveCount(0); + await expect.poll(async () => { + let markerBox = await marker(page).boundingBox(); + let lineCenter = await moved.evaluate(paragraph => { + let box = paragraph.getBoundingClientRect(); + return box.top + Number.parseFloat(getComputedStyle(paragraph).lineHeight) / 2; + }); + return markerBox ? Math.abs(markerBox.y + markerBox.height / 2 - lineCenter) : Infinity; + }).toBeLessThan(2); + await page.screenshot({ path: testInfo.outputPath("decision-anchor-after-cut-paste.png") }); + await moved.scrollIntoViewIfNeeded(); + let glyph = await moved.evaluate(paragraph => { + let range = document.createRange(); + range.selectNodeContents(paragraph); + let rect = [...range.getClientRects()].find(rect => rect.width > 0 && rect.height > 0); + if (!rect) throw new Error("moved prose has no rendered text range"); + let x = rect.left + rect.width / 2; + let y = rect.top + rect.height / 2; + let target = document.elementFromPoint(x, y); + return { x, y, hitsProse: !!target && paragraph.contains(target) }; + }); + expect(glyph.hitsProse).toBe(true); + await page.mouse.move(5, 5); + await page.mouse.move(glyph.x, glyph.y); + await expect(page.getByRole("tooltip")).toContainText(QUESTION); + await page.screenshot({ path: testInfo.outputPath("decision-anchor-after-move-hover.png") }); + expectLinked(await latestDocumentSnapshot(page, room), widget); + }, +); + +test( + "saved decision prose keeps separate markers when both question links point at later checklist", + async ({ join, seed }) => { + let monitoring = "Monitoring uses Sentry for errors."; + let alerts = "Critical errors go to Slack."; + let checklist = "- Browser one\n- Browser two\n- Browser three"; + let ids = [ + { + card: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + question: "01K0N4V4E7Y6P4MJ5WD8XZF3B2", + option: "01K0N4W3B7P27CBAEC7A8C8WEA", + answer: "Sentry", + prose: monitoring, + }, + { + card: "01K0N4X2M5R8T3VQ7YB6ZC4DEF", + question: "01K0N4Y2M5R8T3VQ7YB6ZC4DEF", + option: "01K0N4Z2M5R8T3VQ7YB6ZC4DEF", + answer: "Slack", + prose: alerts, + }, + ]; + let source = + ids.map(item => + `${item.prose}\n\n\n\n\n\n` + ).join("\n") + `\n${checklist}\n`; + let anchor = (text: string) => ({ + epoch: "stale", + position: "AA==", + digest: PlanRoom.digest(`${text}\n`), + }); + await seed(source, { + revision: 1, + questions: ids.map(item => ({ + id: item.card, + status: "answered", + origin: "conversation", + threadId: item.card, + owner: "ana", + decidedAt: 1_758_645_000, + choices: [item.option], + definition: { + questions: [{ + id: item.question, + header: "Decision", + question: "Where should this go?", + multiple: false, + options: [{ id: item.option, label: item.answer, description: "" }], + }], + }, + anchors: { + widget: item.card, + questions: { + [item.question]: { anchors: [anchor(checklist)], pending: false }, + }, + }, + prose: [anchor(item.prose)], + })), + }); + let page = await join("ana", { viewport: { width: 893, height: 850 } }); + let dialog = page.getByRole("dialog", { name: "Decision", exact: true }); + let marker = (answer: string) => + page.getByRole("button", { name: `Decision: ${answer}`, exact: true }); + let verify = async (index: number, keyboard: boolean) => { + let item = ids[index]!; + let target = marker(item.answer); + await target.scrollIntoViewIfNeeded(); + if (keyboard) { + await target.focus(); + await page.keyboard.press("Enter"); + } else { + await target.click(); + } + await expect(dialog).toContainText(item.answer); + let highlighted = await page.evaluate(() => + [...CSS.highlights.get("plan-decision") ?? []] + .map(range => range.toString()).join(" ") + ); + expect(highlighted).toContain(item.prose); + expect(highlighted).not.toContain(checklist); + expect(highlighted).not.toContain(ids[1 - index]!.prose); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + }; + let first = (await marker(ids[0]!.answer).boundingBox())!; + let second = (await marker(ids[1]!.answer).boundingBox())!; + expect(Math.abs(first.y - second.y)).toBeGreaterThan(20); + await verify(0, true); + await verify(1, false); + await page.reload(); + await expect(marker(ids[0]!.answer)).toBeVisible(); + await expect(marker(ids[1]!.answer)).toBeVisible(); + await verify(0, false); + await verify(1, true); + }, +); diff --git a/e2e/decision-evidence.e2e.ts b/e2e/decision-evidence.e2e.ts new file mode 100644 index 00000000..1dd85dc8 --- /dev/null +++ b/e2e/decision-evidence.e2e.ts @@ -0,0 +1,407 @@ +/** Browser coverage for evidence attached to a chat-derived decision card. */ + +import { expect, test } from "./room"; +import { openJevWire, sendChat, wireFrames, wireState } from "./jev-wire"; +import { resetPlannerJobs, scriptJob } from "./planner-jobs"; + +import type { Locator, Page } from "@playwright/test"; + +const QUESTION = "Should we ship a small pilot?"; +const OPTION = "Start with a small pilot."; +const REASON = "A small pilot would catch setup problems early."; +const CONSTRAINT = "Keep the pilot accessible to keyboard-only users."; + +function card(page: Page) { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: QUESTION, exact: true }) }); +} + +function evidence(page: Page) { + return page.getByRole("dialog", { name: `Evidence for ${QUESTION}`, exact: true }); +} + +async function discuss(ana: Page, bo: Page, room: string) { + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, QUESTION); + await sendChat(bo, OPTION); + let reason = await sendChat(ana, REASON); + await sendChat(bo, CONSTRAINT); + await sendChat(ana, "I support the small pilot."); + await sendChat(bo, "I object: a small pilot will exclude keyboard-only users."); + await expect(card(ana)).toBeVisible(); + return reason; +} + +async function openEvidence(page: Page): Promise { + await page.mouse.move(0, 0); + await card(page).getByRole("button", { name: "Inspect decision evidence", exact: true }) + .click(); + let panel = evidence(page); + await expect(panel).toBeVisible(); + return panel; +} + +test.beforeEach(async () => { + await resetPlannerJobs(); +}); + +test.afterEach(async () => { + await resetPlannerJobs(); +}); + +test("a card stays quiet on hover and evidence opens only by its code button", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await discuss(ana, bo, room); + + await card(ana).hover(); + await ana.waitForTimeout(450); + await expect(evidence(ana)).toHaveCount(0); + let trigger = card(ana).getByRole("button", { name: "Inspect decision evidence", exact: true }); + await trigger.focus(); + await expect(evidence(ana)).toHaveCount(0); + await trigger.press("Enter"); + let panel = evidence(ana); + await expect(panel).toBeVisible(); + await ana.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); + + panel = await openEvidence(ana); + let box = await panel.boundingBox(); + let cardBox = await card(ana).boundingBox(); + expect(box).toBeTruthy(); + expect(cardBox).toBeTruthy(); + expect( + box!.x >= cardBox!.x + cardBox!.width || box!.x + box!.width <= cardBox!.x, + ).toBe(true); + await ana.mouse.click(0, 0); + await expect(panel).toHaveCount(0); +}); + +test("evidence shows exact sources and current stances, then source navigation can reopen and leave", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + let reason = await discuss(ana, bo, room); + let panel = await openEvidence(ana); + await expect(panel).toContainText(REASON); + await expect(panel).toContainText(CONSTRAINT); + await expect(panel).toContainText("Supported by ana"); + await expect(panel).toContainText("Opposed by bo"); + await expect(panel.locator("input, select, textarea")).toHaveCount(0); + + let source = panel.getByRole("button", { name: `Show โ€œ${REASON}โ€ in chat`, exact: true }); + await source.focus(); + await expect(source).toBeFocused(); + await ana.mouse.move(0, 0); + await ana.waitForTimeout(200); + await expect(panel).toBeVisible(); + await expect(source).toBeFocused(); + await source.click(); + await expect(ana.locator(`[data-chat-message-id="${reason}"][data-source-exact="true"]`)) + .toBeVisible(); + await expect.poll(() => ana.evaluate(() => CSS.highlights.get("conversation-source")?.size ?? 0)) + .toBe(1); + + panel = await openEvidence(ana); + await panel.hover(); + await ana.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + panel = await openEvidence(ana); + await ana.mouse.click(0, 0); + await expect(panel).toHaveCount(0); +}); + +test("source navigation yields scrolling to the reader and expires its highlight", async ({ join, room }) => { + let ana = await join("ana", { viewport: { width: 775, height: 600 } }); + let bo = await join("bo"); + let reason = await discuss(ana, bo, room); + await sendChat( + bo, + Array.from( + { length: 16 }, + (_, index) => `A later conversation note ${index + 1} gives the chat room to scroll.`, + ).join("\n\n"), + ); + + let panel = await openEvidence(ana); + await panel.getByRole("button", { name: `Show โ€œ${REASON}โ€ in chat`, exact: true }).click(); + let message = ana.locator(`[data-chat-message-id="${reason}"]`); + await expect(message).toHaveAttribute("data-source-exact", "true"); + await expect(message).toBeInViewport(); + await expect(message.locator("[data-source-preview]")).toContainText(REASON); + await expect.poll(() => ana.evaluate(() => CSS.highlights.get("conversation-source")?.size ?? 0)) + .toBe(1); + + let scroller = ana.locator("[data-chat-stack]").locator(".."); + await expect.poll(() => scroller.evaluate(element => element.scrollHeight - element.clientHeight)) + .toBeGreaterThan(100); + await scroller.evaluate(element => element.scrollTop = element.scrollHeight); + let manualTop = await scroller.evaluate(element => element.scrollTop); + await expect(message).not.toBeInViewport(); + await sendChat(bo, "A final update arrives after I scroll away from the source."); + await expect(ana.getByText("A final update arrives after I scroll away from the source.", { + exact: true, + })).toBeVisible(); + await expect.poll(() => scroller.evaluate(element => element.scrollTop)) + .toBeGreaterThanOrEqual(manualTop - 2); + await expect(message).not.toBeInViewport(); + + await expect(message).not.toHaveAttribute("data-source-exact", "true", { timeout: 8_000 }); + await expect(message).not.toHaveClass(/bg-inset/); + await expect(message.locator("[data-source-preview]")).toHaveCount(0); + await expect.poll(() => ana.evaluate(() => CSS.highlights.get("conversation-source")?.size ?? 0)) + .toBe(0); +}); + +test("a narrow panel closes without stale hover after Source or Escape", async ({ join, room }) => { + let ana = await join("ana", { viewport: { width: 775, height: 863 } }); + let bo = await join("bo"); + await discuss(ana, bo, room); + let panel = await openEvidence(ana); + let source = panel.getByRole("button", { name: `Show โ€œ${REASON}โ€ in chat`, exact: true }); + let [sourceBox, panelBox] = await Promise.all([source.boundingBox(), panel.boundingBox()]); + expect(sourceBox).toBeTruthy(); + expect(panelBox).toBeTruthy(); + expect(panelBox!.x).toBeGreaterThanOrEqual(0); + expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(775); + expect(sourceBox!.x).toBeGreaterThanOrEqual(panelBox!.x); + expect(sourceBox!.x + sourceBox!.width).toBeLessThanOrEqual(panelBox!.x + panelBox!.width); + await source.click(); + await expect(panel).toHaveCount(0); + await ana.waitForTimeout(450); + await expect(panel).toHaveCount(0); + + await ana.getByRole("button", { name: "Document", exact: true }).click(); + await expect(card(ana)).toBeVisible(); + await ana.mouse.move(0, 0); + panel = await openEvidence(ana); + panelBox = await panel.boundingBox(); + expect(panelBox).toBeTruthy(); + await ana.mouse.move(panelBox!.x + panelBox!.width / 2, panelBox!.y + panelBox!.height / 2); + await ana.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await ana.waitForTimeout(450); + await expect(panel).toHaveCount(0); + + await ana.mouse.move(0, 0); + await expect(await openEvidence(ana)).toBeVisible(); +}); + +test("an open panel updates to the room's current stance", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, QUESTION); + await sendChat(bo, OPTION); + await sendChat(ana, "I support the small pilot."); + let panel = await openEvidence(ana); + await expect(panel).toContainText("Supported by ana"); + + await sendChat(bo, "I support the small pilot."); + await expect(panel).toContainText("Supported by ana, bo"); + await sendChat(bo, "I object: a small pilot will exclude keyboard-only users."); + await expect(panel).toContainText("Supported by ana"); + await expect(panel).toContainText("Opposed by bo"); + await expect(panel).not.toContainText("Supported by ana, bo"); +}); + +test("Planner option evidence navigates to its exact saved chat source", async ({ join, room }) => { + let ana = await join("ana"); + await openJevWire(ana, room); + let sourceQuote = "Start with a small pilot."; + let sourceId = await sendChat(ana, sourceQuote); + await expect.poll(async () => + (await wireState(ana))?.analysis.find(item => item.messageId === sourceId)?.status + ).toBe("unlinked"); + let source = { + messageId: sourceId, + author: { kind: "member", handle: "ana" }, + quote: sourceQuote, + start: 0, + end: sourceQuote.length, + role: "option", + }; + await scriptJob("refine", [{ + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + title: "Should we run a limited pilot?", + add_options: [ + { + label: sourceQuote, + rationale: "It keeps the pilot small and concrete.", + source, + }, + { + label: "Pilot with two teams", + rationale: "It compares two different settings.", + }, + ], + }, + }]); + await sendChat(ana, QUESTION); + let refined = ana.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ + has: ana.getByRole("heading", { name: "Should we run a limited pilot?", exact: true }), + }); + await expect(refined.getByRole("radio", { name: sourceQuote, exact: true })) + .toBeVisible(); + await expect(refined.getByRole("radio", { name: "Pilot with two teams", exact: true })) + .toBeVisible(); + await expect.poll(async () => { + let meta = (await wireFrames(ana)).findLast(frame => frame.kind === "question:meta")?.meta as + | { optionOrigins: Record } + | undefined; + return Object.values(meta?.optionOrigins ?? {}).find(origin => origin.source)?.source; + }).toEqual(source); + await refined.getByRole("button", { name: "Inspect decision evidence", exact: true }).click(); + let panel = ana.getByRole("dialog", { + name: "Evidence for Should we run a limited pilot?", + exact: true, + }); + await expect(panel).toContainText("Planner suggested"); + await expect(panel).toContainText( + "Why Chopin suggested this: It keeps the pilot small and concrete.", + ); + await expect(panel).toContainText( + "Why Chopin suggested this: It compares two different settings.", + ); + let showSource = panel.getByRole("button", { + name: `Show โ€œ${sourceQuote}โ€ in chat`, + exact: true, + }); + await expect(showSource).toBeVisible(); + await expect(panel.getByRole("button", { name: /Show .* in chat/ })).toHaveCount(1); + await showSource.click(); + let sourceMessage = ana.locator(`[data-chat-message-id="${sourceId}"]`); + await expect(sourceMessage).toHaveAttribute("data-source-exact", "true"); + await expect(sourceMessage).toBeInViewport(); + await expect(sourceMessage.locator("[data-source-preview]")).toContainText(sourceQuote); + await expect.poll(() => ana.evaluate(() => CSS.highlights.get("conversation-source")?.size ?? 0)) + .toBe(1); +}); + +test("a long panel clamps to a short viewport and keeps its own scrollable evidence reachable", async ({ join, room }) => { + let rationale = "This option keeps the pilot concrete while collecting useful delivery evidence. " + .repeat(3).trim(); + await scriptJob("refine", [{ + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + add_options: Array.from({ length: 5 }, (_, index) => ({ + label: `Pilot cohort ${index + 1}`, + rationale, + })), + }, + }]); + let ana = await join("ana", { viewport: { width: 800, height: 360 } }); + await openJevWire(ana, room); + await sendChat(ana, QUESTION); + await expect(card(ana).getByRole("radio", { name: "Pilot cohort 5", exact: true })).toBeVisible(); + let panel = await openEvidence(ana); + let box = await panel.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(800); + expect(box!.y + box!.height).toBeLessThanOrEqual(360); + + let contents = panel.getByLabel("Evidence", { exact: true }); + await expect.poll(() => contents.evaluate(element => element.scrollHeight > element.clientHeight)) + .toBe(true); + await contents.evaluate(element => element.scrollTop = element.scrollHeight); + expect(await contents.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + await expect(panel).toBeVisible(); + await ana.setViewportSize({ width: 500, height: 360 }); + await expect.poll(() => ana.evaluate(() => innerWidth)).toBe(500); + await expect.poll(async () => { + let current = await panel.boundingBox(); + return current ? current.x + current.width : Number.POSITIVE_INFINITY; + }).toBeLessThanOrEqual(500); + box = await panel.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(500); +}); + +test("scrolling the document closes its evidence panel", async ({ join, room, seed }) => { + await seed( + Array.from({ length: 80 }, (_, index) => `Document paragraph ${index + 1}.`).join("\n\n"), + ); + let ana = await join("ana"); + let bo = await join("bo"); + await discuss(ana, bo, room); + let panel = await openEvidence(ana); + let scroller = ana.locator("[data-plan-scroll]"); + let top = await scroller.evaluate(element => element.scrollTop); + expect(top).toBeGreaterThan(0); + await scroller.evaluate(element => element.scrollTo({ top: 0 })); + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(0); + await expect(panel).toHaveCount(0); +}); + +test("empty, remote-closed, and hidden-document cards do not retain evidence portals", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await openJevWire(ana, room); + await openJevWire(bo, room); + await sendChat(ana, QUESTION); + await sendChat(bo, OPTION); + await expect(card(ana)).toBeVisible(); + await card(ana).hover(); + await ana.waitForTimeout(600); + await expect(evidence(ana)).toHaveCount(0); + + await sendChat(ana, REASON); + let panel = await openEvidence(ana); + await ana.getByRole("button", { name: /^Decisions/ }).click(); + await expect(panel).toHaveCount(0); + await ana.getByRole("button", { name: "Document", exact: true }).click(); + panel = await openEvidence(ana); + await bo.getByRole("button", { name: /^Decisions/ }).click(); + let remote = bo.locator( + '[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]', + ) + .filter({ has: bo.getByRole("heading", { name: QUESTION, exact: true }) }); + await remote.getByText(OPTION, { exact: true }).click(); + await remote.getByRole("button", { name: "Save", exact: true }).click(); + await expect(panel).toHaveCount(0); + + await ana.getByRole("button", { name: "Decisions", exact: true }).click(); + await ana.getByRole("button", { name: "1 resolved", exact: true }).click(); + let resolved = ana.locator( + '[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]', + ).filter({ hasText: QUESTION }); + await expect(resolved).toBeVisible(); + await resolved.getByRole("button", { name: "Reopen", exact: true }).click(); + await ana.getByRole("button", { name: "Document", exact: true }).click(); + await expect(card(ana)).toBeVisible(); + panel = await openEvidence(ana); + await ana.getByRole("button", { name: /^Decisions/ }).click(); + await expect(panel).toHaveCount(0); + await ana.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]') + .first().hover(); + await ana.waitForTimeout(450); + await expect(evidence(ana)).toHaveCount(0); +}); + +test("a remote discard removes an open evidence portal", async ({ join, room }) => { + let ana = await join("ana"); + let bo = await join("bo"); + await discuss(ana, bo, room); + let panel = await openEvidence(ana); + await bo.getByRole("button", { name: /^Decisions/ }).click(); + let remote = bo.locator( + '[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]', + ) + .filter({ has: bo.getByRole("heading", { name: QUESTION, exact: true }) }); + await remote.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(remote.getByText("Discard this decision?", { exact: true })).toBeVisible(); + await remote.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(panel).toHaveCount(0); +}); diff --git a/e2e/decision-prose.e2e.ts b/e2e/decision-prose.e2e.ts new file mode 100644 index 00000000..63ffdd4f --- /dev/null +++ b/e2e/decision-prose.e2e.ts @@ -0,0 +1,390 @@ +/** Browser coverage for chat decisions that become anchored document prose. */ + +import { content, expect, ready, test } from "./room"; +import { openJevWire, sendChat, wireFrames, wireState } from "./jev-wire"; +import { resetPlannerJobs, scriptJob } from "./planner-jobs"; + +import type { Locator, Page } from "@playwright/test"; +import type { Question } from "../packages/protocol/index"; + +const QUESTION = "Should we ship a small pilot?"; +const OPTION = "Start with a small pilot."; + +function card(page: Page) { + return page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: QUESTION }) }); +} + +function marker(page: Page) { + return page.getByRole("button", { name: `Decision: ${OPTION}`, exact: true }); +} + +async function proseOrphaned(page: Page): Promise { + let id = (await wireState(page))?.threads[0]?.questionnaireId; + let frames = await wireFrames(page); + for (let frame of frames.toReversed()) { + if (frame.kind === "question:meta" && frame.id === id) { + return (frame as Question.Meta).meta.proseOrphaned; + } + if (frame.kind === "question:metas") { + return (frame as Question.Metas).cards.find(card => card.id === id)?.meta.proseOrphaned; + } + } + return undefined; +} + +function prose(page: Page, option = OPTION) { + return content(page).getByText(`We decided: ${option}`, { exact: true }); +} + +function proseBlock(page: Page) { + return content(page).locator("p").filter({ hasText: "We decided:" }); +} + +async function scriptProse(option: string, target = "$target") { + await scriptJob("prose", [{ + tool: "write_decision_prose", + args: { revision: "$revision", id: target, text: `We decided: ${option}` }, + }]); +} + +async function waitForProseJob(page: Page, status: string) { + await expect.poll(async () => { + let frames = await wireFrames(page); + let current = frames.findLast(frame => + frame.kind === "conversation-plan:jobs" || frame.kind === "conversation-plan:snapshot" + ) as { jobs?: Array<{ kind?: string; status?: string }> } | undefined; + return current?.jobs?.find(job => job.kind === "prose")?.status; + }).toBe(status); +} + +async function decide(page: Page, room: string, option = OPTION) { + await scriptProse(option); + await openJevWire(page, room); + let source = await sendChat(page, QUESTION); + await sendChat(page, OPTION); + let decision = card(page); + await expect(decision).toBeVisible(); + await decision.getByText(option, { exact: true }).click(); + await expect(decision.getByRole("radio", { name: option, exact: true })).toBeChecked(); + await decision.getByRole("button", { name: "Save", exact: true }).click(); + await expect(prose(page, option)).toBeVisible(); + await waitForProseJob(page, "done"); + return source; +} + +async function openPopover(page: Page): Promise { + await marker(page).click(); + let popover = page.getByRole("dialog", { name: "Decision", exact: true }); + await expect(popover).toBeVisible(); + return popover; +} + +test.beforeEach(async () => { + await resetPlannerJobs(); +}); + +test.afterEach(async () => { + await resetPlannerJobs(); +}); + +test("Save writes prose, collapses the card, marks its margin, and records activity", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + + await expect(card(page)).toHaveCount(0); + await expect(marker(page)).toBeVisible(); + await expect(page.getByText("Chopin wrote up", { exact: false })).toBeVisible(); + let [mark, paragraph] = await Promise.all([ + marker(page).boundingBox(), + prose(page).boundingBox(), + ]); + expect(mark).toBeTruthy(); + expect(paragraph).toBeTruthy(); + expect(mark!.x + mark!.width).toBeLessThanOrEqual(paragraph!.x); + expect(Math.abs(mark!.y - paragraph!.y)).toBeLessThan(12); + + let hidden = page.locator('[data-document-view="plan"] [data-plan-collapsed]'); + await expect(hidden).toHaveCount(1); + await expect(hidden).toBeHidden(); +}); + +test("hover previews and washes prose; pin exposes source and Escape restores marker focus", async ({ join, room }) => { + let page = await join("ana"); + let source = await decide(page, room); + + await prose(page).hover(); + let preview = page.getByRole("tooltip"); + await expect(preview).toContainText(QUESTION); + await expect(preview).toHaveCSS("opacity", "1"); + await expect(preview).toHaveCSS("transform", "none"); + await expect.poll(() => page.evaluate(() => CSS.highlights.get("plan-decision")?.size ?? 0)) + .toBeGreaterThan(0); + + let popover = await openPopover(page); + await expect(popover).toHaveCSS("opacity", "1"); + await expect(popover).toHaveCSS("transform", "none"); + await expect(popover.getByRole("img", { name: "ana", exact: true })).toBeVisible(); + await expect(popover.getByText("ana", { exact: true })).toBeVisible(); + await popover.getByRole("button", { name: "Show source in chat", exact: true }).click(); + await expect(page.locator(`[data-chat-message-id="${source}"][data-source-exact="true"]`)) + .toBeVisible(); + + if (!await popover.isVisible()) popover = await openPopover(page); + await expect(popover).toBeVisible(); + await expect(marker(page)).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + await expect(marker(page)).toBeFocused(); +}); + +test("hiding the Document clears pinned decision chrome and its wash before marker recovery", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + await openPopover(page); + await expect.poll(() => page.evaluate(() => CSS.highlights.get("plan-decision")?.size ?? 0)) + .toBeGreaterThan(0); + await page.getByRole("button", { name: "Decisions", exact: true }).click(); + await expect(page.locator(".plan-decision-layer [role=dialog]")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => CSS.highlights.get("plan-decision")?.size ?? 0)).toBe( + 0, + ); + await page.getByRole("button", { name: "Document", exact: true }).click(); + await expect(marker(page)).toBeVisible(); +}); + +test("ordinary prose edits retain the marker; deletion orphans it for both readers after reload", async ({ join, room }) => { + let ana = await join("ana"); + let ben = await join("ben"); + await openJevWire(ben, room); + await decide(ana, room); + await expect(marker(ben)).toBeVisible(); + + await prose(ana).click(); + await ana.keyboard.press("End"); + await ana.keyboard.type(" For one team."); + await expect(marker(ana)).toBeVisible(); + await expect(marker(ben)).toBeVisible(); + + let edited = proseBlock(ana); + await edited.selectText(); + await ana.keyboard.press("Backspace"); + await expect(marker(ana)).toHaveCount(0); + await expect(marker(ben)).toHaveCount(0); + await ana.getByRole("button", { name: /^Decisions/ }).click(); + await ana.getByRole("button", { name: "1 resolved", exact: true }).click(); + await expect.poll(() => proseOrphaned(ana)).toBe(true); + await expect(ana.locator('[data-document-view="decisions"]')).toContainText(OPTION); + + await ben.reload(); + await ready(ben); + await openJevWire(ben, room); + await ben.getByRole("button", { name: /^Decisions/ }).click(); + await ben.getByRole("button", { name: "1 resolved", exact: true }).click(); + await expect.poll(() => proseOrphaned(ben)).toBe(true); + await expect(ben.locator('[data-document-view="decisions"]')).toContainText(OPTION); +}); + +test("Backspace after a hidden decided card moves into its prose without deleting the decision", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + let trailing = content(page).locator(":scope > p").last(); + await expect(trailing).toBeEmpty(); + await trailing.click(); + await page.keyboard.type("Following detail."); + let following = content(page).locator("p").filter({ hasText: "Following detail." }); + await expect(following).toHaveText("Following detail."); + await page.keyboard.press(process.platform === "darwin" ? "Meta+ArrowLeft" : "Home"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(" Updated."); + + await expect(proseBlock(page)).toContainText(`We decided: ${OPTION} Updated.`); + await expect(content(page)).toContainText("Following detail."); + await expect(marker(page)).toBeVisible(); + await page.getByRole("button", { name: /^Decisions/ }).click(); + await page.getByRole("button", { name: "1 resolved", exact: true }).click(); + await expect.poll(() => proseOrphaned(page)).toBe(false); + await page.reload(); + await page.getByRole("button", { name: "Document", exact: true }).click(); + await expect(proseBlock(page)).toContainText(`We decided: ${OPTION} Updated.`); + await expect(content(page)).toContainText("Following detail."); + await expect(marker(page)).toBeVisible(); +}); + +test("Backspace from an empty paragraph after resolved prose keeps the decision", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + await proseBlock(page).selectText(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Enter"); + await page.keyboard.press("Backspace"); + + await expect(content(page)).toContainText(`We decided: ${OPTION}`); + await expect(marker(page)).toBeVisible(); + await page.getByRole("button", { name: /^Decisions/ }).click(); + await page.getByRole("button", { name: "1 resolved", exact: true }).click(); + await expect.poll(() => proseOrphaned(page)).toBe(false); +}); + +test("Reopen leaves prose, restores the card beneath it, and a re-decision replaces it", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + await sendChat(page, "Keep the pilot accessible to keyboard-only users."); + + let popover = await openPopover(page); + await popover.getByRole("button", { name: "Reopen", exact: true }).click(); + let reopened = card(page); + await expect(reopened).toContainText("Previously: Start with a small pilot. ยท @ana"); + let [paragraph, reopenedCard] = await Promise.all([ + prose(page).boundingBox(), + reopened.boundingBox(), + ]); + expect(paragraph).toBeTruthy(); + expect(reopenedCard).toBeTruthy(); + expect(paragraph!.y).toBeLessThan(reopenedCard!.y); + + await reopened.getByRole("button", { name: "Add an option", exact: true }).click(); + await reopened.getByRole("textbox", { name: "New option", exact: true }).fill( + "Run a full launch", + ); + await page.keyboard.press("Enter"); + await reopened.getByText("Run a full launch", { exact: true }).click(); + await expect(reopened.getByRole("radio", { name: "Run a full launch", exact: true })) + .toBeChecked(); + await scriptProse("Run a full launch"); + await reopened.getByRole("button", { name: "Save", exact: true }).click(); + await expect(prose(page, "Run a full launch")).toBeVisible(); + await expect(content(page).getByText(/^We decided:/)).toHaveCount(1); +}); + +test("Discard removes only the marker and retains the prose", async ({ join, room }) => { + let page = await join("ana"); + await decide(page, room); + let popover = await openPopover(page); + await popover.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(popover).toContainText("Discard this decision?"); + await popover.getByRole("button", { name: "Discard decision", exact: true }).click(); + await expect(marker(page)).toHaveCount(0); + await expect(prose(page)).toBeVisible(); +}); + +test("a narrow document retains its resolved-prose lane while the marker scrolls away", async ({ join, room }) => { + let page = await join("ana", { viewport: { width: 800, height: 600 } }); + await decide(page, room); + let resolved = `We decided: ${OPTION}`; + let edited = `${resolved} Still here.`; + await prose(page).selectText(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.type(" Still here."); + await expect(content(page).getByText(edited, { exact: true })).toBeVisible(); + for (let index = 0; index < 32; index++) { + await page.keyboard.press("Enter"); + await page.keyboard.type("Later implementation detail."); + } + expect( + await content(page).locator("p").evaluateAll(nodes => + nodes.map(node => node.textContent?.trim()).find(text => text) + ), + ).toBe(edited); + let scroller = page.locator("[data-plan-scroll]"); + let gutter = await content(page).evaluate(element => + Number.parseFloat(getComputedStyle(element).paddingInlineStart) + ); + expect(gutter).toBeGreaterThan(0); + await scroller.evaluate(element => element.scrollTop = element.scrollHeight); + expect( + await content(page).evaluate(element => + Number.parseFloat(getComputedStyle(element).paddingInlineStart) + ), + ).toBe(gutter); + await expect(marker(page)).toHaveCount(0); + await scroller.evaluate(element => element.scrollTop = 0); + await expect(marker(page)).toBeVisible(); + let [mark, paragraph] = await Promise.all([ + marker(page).boundingBox(), + proseBlock(page).boundingBox(), + ]); + expect(mark).toBeTruthy(); + expect(paragraph).toBeTruthy(); + expect(mark!.x + mark!.width).toBeLessThanOrEqual(paragraph!.x); +}); + +test("a failed prose job is visible on the opening message and Retry uses the corrected script", async ({ join, room }) => { + let page = await join("ana"); + await scriptProse(OPTION, "another-card"); + await openJevWire(page, room); + let source = await sendChat(page, QUESTION); + await sendChat(page, OPTION); + await card(page).getByText(OPTION, { exact: true }).click(); + await card(page).getByRole("button", { name: "Save", exact: true }).click(); + await waitForProseJob(page, "failed"); + let opening = page.locator(`[data-chat-message-id="${source}"]`); + await opening.getByRole("button", { name: /Analysis for message/ }).click(); + let jobs = page.getByRole("group", { name: "Planner jobs" }); + await expect(jobs).toContainText("prose ยท failed"); + await scriptProse(OPTION); + await jobs.getByRole("button", { name: "Retry prose job", exact: true }).click(); + await waitForProseJob(page, "done"); + await expect(prose(page)).toBeVisible(); +}); + +test("the collapse has an intermediate inert state, respects reduced motion, and rapid reopen remains usable", async ({ join, room }) => { + let page = await join("ana"); + await scriptProse(OPTION); + await openJevWire(page, room); + await sendChat(page, QUESTION); + await sendChat(page, OPTION); + let decision = card(page); + await decision.getByText(OPTION, { exact: true }).click(); + await decision.getByRole("button", { name: "Save", exact: true }).click(); + let closing = page.locator("[data-decision-collapsing]"); + await expect(closing).toHaveCount(1); + await expect(closing).toHaveAttribute("aria-hidden", "true"); + await expect(closing).toHaveAttribute("inert", ""); + let heights = await closing.evaluate(async element => { + let samples: number[] = []; + for (let index = 0; index < 4; index++) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + samples.push(element.getBoundingClientRect().height); + } + return samples; + }); + expect(heights[0]).toBeGreaterThan(0); + expect(heights.some(height => height > 0 && height < heights[0]!)).toBe(true); + await expect(marker(page)).toBeVisible(); + let popover = await openPopover(page); + await expect(closing).toHaveCount(1); + let reopen = popover.getByRole("button", { name: "Reopen", exact: true }); + await reopen.evaluate(button => { + document.addEventListener( + "click", + event => { + if (event.composedPath().includes(button)) { + (window as Window & { reopenedWhileClosing?: boolean }).reopenedWhileClosing = + document.querySelector("[data-decision-collapsing]") !== null; + } + }, + { capture: true, once: true }, + ); + }); + let reopenBox = await reopen.boundingBox(); + expect(reopenBox).toBeTruthy(); + await page.mouse.click(reopenBox!.x + reopenBox!.width / 2, reopenBox!.y + reopenBox!.height / 2); + expect( + await page.evaluate(() => + (window as Window & { reopenedWhileClosing?: boolean }).reopenedWhileClosing + ), + ) + .toBe(true); + await expect(card(page)).toBeVisible(); + await card(page).getByText(OPTION, { exact: true }).click(); + await expect(card(page).getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await card(page).getByText(OPTION, { exact: true }).click(); + await scriptProse(OPTION); + await card(page).getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.locator("[data-decision-collapsing]")).toHaveCount(0); + await expect(marker(page)).toBeVisible(); + await page.emulateMedia({ reducedMotion: "no-preference" }); +}); diff --git a/e2e/harness/scripted-environment.ts b/e2e/harness/scripted-environment.ts new file mode 100644 index 00000000..e72ab9b4 --- /dev/null +++ b/e2e/harness/scripted-environment.ts @@ -0,0 +1,51 @@ +import { join } from "node:path"; + +export const SCRIPTED_HARNESS = "e2e-prompt-scripted"; + +/** Explicit caller selection; these checks do not prove database or network isolation. */ +export function requireScriptedServer(env: Record, root: string): void { + let expected = { + E2E_CONVERSATION_PLAN: "1", + SERVER_HOST: "127.0.0.1", + PORT: "8788", + APP_ORIGIN: "http://127.0.0.1:8788", + AGENT: "on", + CONVERSATION_PLAN: "on", + HARNESS: SCRIPTED_HARNESS, + HARNESS_AUTH: "direct", + JEV_API_KEY: "e2e-jev-only", + JEV_MODEL: "jev-e2e", + TYPESAFE_API_KEY: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + NO_PROXY: "*", + no_proxy: "*", + E2E_PLANNER_JOBS_DIR: join(root, "e2e", "test-results", "planner-jobs"), + E2E_JEV_CONTROL_DIR: join(root, "e2e", "test-results", "jev-control"), + STORAGE_DRIVER: "postgres", + }; + for (let [name, value] of Object.entries(expected)) { + if (env[name] !== value) throw new Error(`Scripted Planner requires explicit ${name}`); + } + if (!/^[a-fA-F0-9]{64}$/.test(env.SESSION_ENCRYPTION_KEY ?? "")) { + throw new Error("Scripted Planner requires a 64-hex SESSION_ENCRYPTION_KEY"); + } + let database: URL; + try { + database = new URL(env.DATABASE_URL ?? ""); + } catch { + throw new Error("Scripted Planner requires a supplied local PostgreSQL DATABASE_URL"); + } + if ( + !["postgres:", "postgresql:"].includes(database.protocol) + || !["127.0.0.1", "localhost", "[::1]"].includes(database.hostname) + || database.pathname.length <= 1 + || ["host", "hostaddr", "service"].some(name => database.searchParams.has(name)) + ) { + throw new Error("Scripted Planner requires a supplied local PostgreSQL DATABASE_URL"); + } +} diff --git a/e2e/harness/scripted-network-preload.ts b/e2e/harness/scripted-network-preload.ts new file mode 100644 index 00000000..457b37be --- /dev/null +++ b/e2e/harness/scripted-network-preload.ts @@ -0,0 +1,7 @@ +import { ROOT } from "../servers"; +import { requireScriptedServer } from "./scripted-environment"; +import { createScriptedFetch } from "./scripted-network"; + +requireScriptedServer(process.env, ROOT); +let network = globalThis.fetch; +globalThis.fetch = createScriptedFetch(network); diff --git a/e2e/harness/scripted-network.ts b/e2e/harness/scripted-network.ts new file mode 100644 index 00000000..8508dc99 --- /dev/null +++ b/e2e/harness/scripted-network.ts @@ -0,0 +1,59 @@ +export type ScriptedFetch = { + (input: Parameters[0], init?: Parameters[1]): Promise; + preconnect?: typeof fetch.preconnect; +}; + +/** Fixture-only fetch boundary, installed before the GitHub, Jev and exact MCP fakes. */ +export function createScriptedFetch(network: ScriptedFetch): typeof fetch { + function allow(input: Parameters[0]): URL { + let url: URL; + try { + if (!(typeof input === "string" || input instanceof URL || input instanceof Request)) { + throw new Error(); + } + url = new URL(input instanceof Request ? input.url : input); + } catch { + throw new Error("Scripted Planner network request refused"); + } + if ( + url.username || url.password || url.protocol !== "http:" + || !["http://127.0.0.1:8788", "http://127.0.0.1:8797"].includes(url.origin) + ) { + throw new Error("Scripted Planner network request refused"); + } + return url; + } + + let guarded = async (input: Parameters[0], init?: Parameters[1]) => { + allow(input); + let request: Request; + try { + // Native normalization retains inherited options and snapshots a mutable URL. + let original = new Request(input, init); + // Fixture HTTP never follows redirects, even to another local origin. + request = new Request(original, { redirect: "error" }); + } catch { + throw new Error("Scripted Planner network request refused"); + } + allow(request); + return network(request); + }; + let preconnect = network.preconnect; + return Object.assign(guarded, { + preconnect(input: string | URL, options?: Parameters[1]) { + let captured: Parameters[1]; + try { + captured = options === undefined ? undefined : { + dns: options.dns, + tcp: options.tcp, + http: options.http, + https: options.https, + }; + } catch { + throw new Error("Scripted Planner network request refused"); + } + let url = allow(input); + preconnect?.call(network, url.href, captured); + }, + }); +} diff --git a/e2e/harness/scripted-preload.ts b/e2e/harness/scripted-preload.ts new file mode 100644 index 00000000..84391442 --- /dev/null +++ b/e2e/harness/scripted-preload.ts @@ -0,0 +1,31 @@ +import { ROOT } from "../servers"; +import { registerScriptedHarness } from "./scripted-register"; + +await registerScriptedHarness(process.env, ROOT); +let { startFakeGithubMcpServer } = await import("./fake-mcp-server"); + +let mcp = startFakeGithubMcpServer(); +let network = globalThis.fetch; + +// Loaded after the GitHub and Jev preloads; unknown URLs retain their existing wrappers. +let fake = async (input: Parameters[0], init?: Parameters[1]) => { + let request = input instanceof Request ? input : undefined; + let url = new URL(request ? request.url : input as string | URL); + if (url.href === "https://api.githubcopilot.com/mcp/") { + let headers = new Headers(init?.headers ?? request?.headers); + let body = init?.body !== undefined + ? init.body + : request + ? await request.clone().arrayBuffer() + : undefined; + return network(mcp.url, { + ...init, + method: init?.method ?? request?.method ?? "POST", + headers, + body, + signal: init?.signal !== undefined ? init.signal : request?.signal, + }); + } + return network(input, init); +}; +globalThis.fetch = Object.assign(fake, { preconnect: network.preconnect }); diff --git a/e2e/harness/scripted-register.test.ts b/e2e/harness/scripted-register.test.ts new file mode 100644 index 00000000..c10b8a30 --- /dev/null +++ b/e2e/harness/scripted-register.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { environment } from "../../scripts/conversation-plan-worker"; +import { ROOT } from "../servers"; + +// A nonexistent local database URL is parsed only; discovery performs no SQL operation. +let env = environment({ + nonce: "a".repeat(32), + key: "b".repeat(64), + commit: "c".repeat(40), + database: "postgres://offline:placeholder@127.0.0.1:29999/offline?sslmode=disable", +}); +env.E2E_PLANNER_JOBS_DIR = join(ROOT, "e2e/test-results/planner-jobs"); +env.E2E_JEV_CONTROL_DIR = join(ROOT, "e2e/test-results/jev-control"); + +let code = ` +import { ROOT } from "./e2e/servers"; +import { registerScriptedHarness } from "./e2e/harness/scripted-register"; +let denied = () => { throw new Error("Discovery must not open a listener or fetch"); }; +globalThis.fetch = denied; +Bun.serve = denied; +await registerScriptedHarness(process.env, ROOT); +await import("./apps/server/src/harness/agents"); +if (globalThis.fetch !== denied || Bun.serve !== denied) throw new Error("Registration changed transport"); +console.log("scripted-discovery-ready"); +`; + +test("scripted registration permits agent discovery without starting transport", async () => { + let child = Bun.spawn([process.execPath, "--no-env-file", "-e", code], { + cwd: ROOT, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let ready = Promise.withResolvers(); + let stdout = ""; + let output = (async () => { + let reader = child.stdout.getReader(); + let decoder = new TextDecoder(); + while (true) { + let { done, value } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + if (stdout.includes("scripted-discovery-ready\n")) ready.resolve(); + } + stdout += decoder.decode(); + if (!stdout.includes("scripted-discovery-ready\n")) { + ready.reject(new Error(`Discovery exited before ready: ${stdout}`)); + } + return stdout; + })(); + void output.catch(ready.reject); + // Cold module discovery competes with the full unit suite; only a ready child must exit promptly. + let timer = setTimeout(() => child.kill("SIGKILL"), 60_000); + try { + await ready.promise; + clearTimeout(timer); + timer = setTimeout(() => child.kill("SIGKILL"), 5_000); + let [exit, completeOutput, stderr] = await Promise.all([ + child.exited, + output, + new Response(child.stderr).text(), + ]); + expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" }); + expect(completeOutput.trim()).toBe("scripted-discovery-ready"); + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill("SIGKILL"); + } +}, 70_000); diff --git a/e2e/harness/scripted-register.ts b/e2e/harness/scripted-register.ts new file mode 100644 index 00000000..756d8f17 --- /dev/null +++ b/e2e/harness/scripted-register.ts @@ -0,0 +1,15 @@ +import { requireScriptedServer, SCRIPTED_HARNESS } from "./scripted-environment"; + +/** Register the model fixture without listeners or fetch wrappers in discovery processes. */ +export async function registerScriptedHarness( + env: Record, + root: string, +) { + requireScriptedServer(env, root); + let { harnesses } = await import("../../apps/server/src/harness/harnesses"); + let { createPromptScriptedHarness } = await import("./scripted-planner"); + let scriptDir = env.E2E_PLANNER_JOBS_DIR!; + Object.assign(harnesses, { + [SCRIPTED_HARNESS]: () => createPromptScriptedHarness(scriptDir).fake, + }); +} diff --git a/e2e/jev-control.ts b/e2e/jev-control.ts new file mode 100644 index 00000000..e407de98 --- /dev/null +++ b/e2e/jev-control.ts @@ -0,0 +1,16 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { ROOT } from "./servers"; + +/** Files are a test-process-to-preload latch, never an application route. */ +export const JEV_CONTROL_DIR = join(ROOT, "e2e", "test-results", "jev-control"); + +export async function resetJevControl(): Promise { + await rm(JEV_CONTROL_DIR, { recursive: true, force: true }); + await mkdir(JEV_CONTROL_DIR, { recursive: true }); +} + +export async function releaseJev(name: "delayed-question"): Promise { + await writeFile(join(JEV_CONTROL_DIR, name), "release"); +} diff --git a/e2e/jev-process.ts b/e2e/jev-process.ts new file mode 100644 index 00000000..da866ffe --- /dev/null +++ b/e2e/jev-process.ts @@ -0,0 +1,85 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + +import { expect } from "@playwright/test"; + +import { JEV_CONTROL_DIR } from "./jev-control"; +import { FIXTURES, HOST, ROOT } from "./servers"; + +export const RESTART_URL = `http://${HOST}:${FIXTURES}`; + +/** A separate app against database 1; the main Playwright server owns database 0. */ +export async function startJevProcess(): Promise { + let database = process.env.E2E_DATABASE_URL_1; + if (!database) throw new Error("restart test requires disposable database 1"); + let child = spawn("bun", [ + "--preload", + "./e2e/github.ts", + "--preload", + "./e2e/jev.ts", + "apps/server/src/main.ts", + ], { + cwd: ROOT, + env: { + ...process.env, + PORT: String(FIXTURES), + SERVER_HOST: HOST, + AGENT: "off", + HARNESS: "copilot-sdk", + HARNESS_AUTH: "direct", + CONVERSATION_PLAN: "on", + JEV_API_KEY: "e2e-jev-only", + JEV_MODEL: "jev-e2e", + TYPESAFE_API_KEY: "", + E2E_JEV_CONTROL_DIR: JEV_CONTROL_DIR, + BACKGROUND_JOBS: "on", + WEB_RESEARCH: "on", + STORAGE_DRIVER: "postgres", + DATABASE_URL: database, + APP_ORIGIN: RESTART_URL, + GITHUB_APP_SLUG: "chopin-e2e", + GITHUB_APP_CLIENT_ID: "e2e", + GITHUB_APP_CLIENT_SECRET: "e2e", + GITHUB_ALLOWED_USERS: "", + GITHUB_ALLOWED_ORGANIZATIONS: "githubnext", + DEV_QUESTIONS: "", + DEV_COMMENTS: "", + }, + }); + let output = ""; + for (let stream of [child.stdout, child.stderr]) { + stream.on("data", chunk => output = (output + String(chunk)).slice(-4000)); + } + try { + await expect.poll(async () => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`restart app exited: ${output}`); + } + try { + return (await fetch(RESTART_URL)).ok; + } catch { + return false; + } + }, { timeout: 15_000 }).toBe(true); + return child; + } catch (error) { + await stopJevProcess(child); + throw new Error(`restart app did not become ready: ${output}`, { cause: error }); + } +} + +export async function stopJevProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + let exited = new Promise(resolve => child.once("exit", () => resolve())); + child.kill("SIGTERM"); + let timer: ReturnType | undefined; + try { + await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("restart app did not stop")), 5_000); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/e2e/jev-wire.ts b/e2e/jev-wire.ts new file mode 100644 index 00000000..937a9523 --- /dev/null +++ b/e2e/jev-wire.ts @@ -0,0 +1,90 @@ +import { expect } from "@playwright/test"; + +import type { ConversationPlan } from "../packages/protocol/index"; +import type { Page } from "@playwright/test"; + +export type WireFrame = { kind: string; rid?: string; [key: string]: unknown }; +type BrowserWire = { socket: WebSocket; frames: WireFrame[] }; + +/** A second real browser socket, with the same cookie and admission as the UI. */ +export async function openJevWire(page: Page, channelId: string): Promise { + await page.evaluate(async id => { + let target = window as typeof window & { __jevWire?: BrowserWire }; + target.__jevWire?.socket.close(); + let url = new URL("/ws", location.href); + url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("channel", id); + let socket = new WebSocket(url); + let frames: WireFrame[] = []; + target.__jevWire = { socket, frames }; + socket.addEventListener("message", event => { + if (typeof event.data === "string") frames.push(JSON.parse(event.data) as WireFrame); + }); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("Jev test socket failed")), { + once: true, + }); + }); + socket.send(JSON.stringify({ kind: "plan:open", ts: 0, rid: crypto.randomUUID() })); + }, channelId); + await expect.poll(async () => + (await wireFrames(page)).some(frame => frame.kind === "conversation-plan:snapshot") + ).toBe(true); +} + +export async function wireFrames(page: Page): Promise { + return page.evaluate(() => { + let target = window as typeof window & { __jevWire?: BrowserWire }; + return target.__jevWire?.frames ?? []; + }); +} + +export async function wireState(page: Page): Promise { + let frames = await wireFrames(page); + let latest = frames.findLast(frame => + frame.kind === "conversation-plan:snapshot" || frame.kind === "conversation-plan:changed" + ); + return latest?.state as ConversationPlan.State | undefined; +} + +export async function wireRequest( + page: Page, + frame: Omit, +): Promise { + return page.evaluate(async input => { + let target = window as typeof window & { __jevWire?: BrowserWire }; + let socket = target.__jevWire?.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error("Jev test socket closed"); + let rid = crypto.randomUUID(); + return await new Promise((resolve, reject) => { + let timeout = setTimeout(() => { + socket.removeEventListener("message", onMessage); + reject(new Error(`no reply for ${input.kind}`)); + }, 10_000); + let onMessage = (event: MessageEvent) => { + if (typeof event.data !== "string") return; + let reply = JSON.parse(event.data) as WireFrame; + if (reply.rid !== rid) return; + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + resolve(reply); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({ ...input, rid, ts: 0 })); + }); + }, frame); +} + +export async function sendChat(page: Page, text: string): Promise { + let reply = await wireRequest(page, { + kind: "chat:send", + requestId: crypto.randomUUID(), + text, + to: "room", + }); + expect(reply.kind).toBe("chat:send"); + expect(reply.queued).toBe(false); + expect(reply.id).toEqual(expect.any(String)); + return reply.id as string; +} diff --git a/e2e/jev.ts b/e2e/jev.ts new file mode 100644 index 00000000..66af5eed --- /dev/null +++ b/e2e/jev.ts @@ -0,0 +1,378 @@ +/** Fake only TypeSafe HTTP. The real adapter, policy, room and wire still run. */ +import { join } from "node:path"; + +import type { JevQuestion, JevRequest } from "../apps/server/src/conversation-plan/jev"; + +let network = globalThis.fetch; +let failures = new Map(); + +type Role = + | "question" + | "option" + | "reason" + | "constraint" + | "support" + | "objection" + | "resolution" + | "reopening" + | "none"; +type Scenario = { + role: Role; + optionText?: string; + roles?: Role[]; + triageRoles?: Role[]; + owned?: boolean[]; + triageOnly?: boolean; + directNewChoice?: boolean; + significance?: number; + target?: { + preceding?: readonly string[]; + recent?: readonly string[]; + }; + delay?: boolean; + failTimes?: number; + material?: boolean; + purpose?: boolean; + agree?: boolean; + spikeChoice?: boolean; +}; + +const SCENARIOS: Record = { + "Which editor should we use?": { role: "question", purpose: true }, + "Lexical": { role: "option" }, + "Monaco": { role: "option" }, + "I'd pick Lexical for the spike;": { + role: "support", + triageRoles: ["support"], + optionText: "Lexical", + spikeChoice: true, + }, + "yep, Lexical for the spike. not a final library call yet.": { + role: "support", + roles: ["support", "none"], + triageRoles: ["support"], + owned: [true, false], + optionText: "Lexical", + agree: true, + }, + "yep, Lexical for the spike.": { + role: "support", + optionText: "Lexical", + agree: true, + }, + "not a final library call yet.": { role: "none" }, + "Should we ship a small pilot?": { role: "question", purpose: true }, + "Which service should send notification emails?": { role: "question", purpose: true }, + "Should our team use an AI coding agent?": { role: "question", purpose: true }, + "Should we store uploaded files in Amazon S3 or on a local disk?": { + role: "question", + purpose: true, + }, + "Amazon S3": { role: "option" }, + "on a local disk": { role: "option" }, + "We should use Postmark for notification emails.": { + role: "option", + directNewChoice: true, + }, + "Start with a small pilot.": { role: "option" }, + "Start with a small pilot. **Keep the pilot accessible to keyboard-only users.**": { + role: "option", + roles: ["option", "constraint"], + }, + "A small pilot would catch setup problems early.": { role: "reason" }, + "Keep the pilot accessible to keyboard-only users.": { role: "constraint" }, + "**Keep the pilot accessible to keyboard-only users.**": { role: "constraint" }, + "SQS would reduce operations for us.": { role: "none", significance: 2 }, + "Copilot or bring-your-own API keys would simplify agent access for our team.": { + role: "none", + significance: 2, + }, + "I support the small pilot.": { role: "support" }, + "Sounds good to me.": { + role: "support", + agree: true, + target: { preceding: [""], recent: ["Let's just go with a small pilot."] }, + }, + "What about agent access?": { + role: "question", + target: { + preceding: [ + "Sounds good to me. ", + "Sounds good to me. I am concerned that a small pilot will exclude keyboard-only users. ", + ], + recent: ["Let's just go with a small pilot."], + }, + }, + "Copilot?": { + role: "none", + target: { + preceding: ["Sounds good to me. What about agent access? "], + recent: ["Let's just go with a small pilot."], + }, + }, + "BYO API keys?": { + role: "none", + target: { + preceding: ["Sounds good to me. What about agent access? Copilot? "], + recent: ["Let's just go with a small pilot."], + }, + }, + "I am concerned that a small pilot will exclude keyboard-only users.": { + role: "objection", + material: true, + target: { + preceding: ["Sounds good to me. "], + recent: ["Let's just go with a small pilot."], + }, + }, + "Sounds good to me. What about agent access? Copilot? BYO API keys?": { + role: "support", + triageRoles: ["support", "question"], + owned: [true, true, true, true], + triageOnly: true, + }, + "Sounds good to me. I am concerned that a small pilot will exclude keyboard-only users. What about agent access?": + { + role: "support", + triageRoles: ["support", "objection", "question"], + owned: [true, true, true], + triageOnly: true, + }, + "Let's do a small pilot.": { role: "resolution", optionText: "Start with a small pilot." }, + "Let's just go with a small pilot.": { + role: "resolution", + optionText: "Start with a small pilot.", + }, + "Let's just go with Ship to everyone.": { + role: "resolution", + optionText: "Ship to everyone", + }, + "I object: a small pilot will exclude keyboard-only users.": { + role: "objection", + material: true, + }, + "Let's revisit the small pilot decision.": { role: "reopening" }, + "Maybe that?": { role: "none" }, + "Thanks, I'll review the draft.": { role: "none" }, + "Could we test with one team first?": { role: "question", failTimes: 1 }, + "Could we retry a flaky question twice?": { role: "question", failTimes: 2 }, + "Should we test with one team first?": { role: "question", delay: true }, +}; + +function choice(question: Extract, wanted: string) { + let keys = Object.keys(question.criteria); + if (!keys.includes(wanted)) throw new Error(`Jev fixture missing choice ${wanted}`); + let probabilities = Object.fromEntries(keys.map(key => [ + key, + key === wanted ? 0.96 : 0.04 / (keys.length - 1), + ])); + return { type: "choice", choice: wanted, confidence: 0.96, probabilities }; +} + +function score(question: Extract, level: number) { + let probabilities = Object.fromEntries(question.criteria.map((_, index) => [ + String(index), + index === level ? 1 : 0, + ])); + return { + type: "score", + score: level, + confidence: 0.96, + legend: Object.fromEntries(question.criteria.map((label, index) => [String(index), label])), + probabilities, + }; +} + +function answers(request: JevRequest, scenario: Scenario) { + let state = request.state as { + threads: Array<{ id: string; options: Array<{ id: string; text: string }> }>; + }; + let thread = state.threads[0]; + let option = scenario.optionText + ? thread?.options.find(item => item.text === scenario.optionText) + : thread?.options[0]; + let answer: Record = {}; + let triage = "new_question" in request.questions; + for (let [id, question] of Object.entries(request.questions)) { + let candidate = /^c(\d+)_/.exec(id); + let role = candidate + ? scenario.directNewChoice + ? "resolution" + : scenario.roles?.[Number(candidate[1])] ?? scenario.role + : scenario.role; + let triageRoles = scenario.triageRoles ?? [scenario.role]; + let hasRole = (wanted: Role) => triage ? triageRoles.includes(wanted) : role === wanted; + let relevant = role !== "none"; + let flags: Record = { + enough_purpose: !!scenario.purpose, + new_question: hasRole("question"), + new_option: hasRole("option") || (!!scenario.directNewChoice && !triage), + reason: hasRole("reason"), + constraint: hasRole("constraint"), + evidence: false, + assumption: false, + support: hasRole("support") || (!!scenario.directNewChoice && !triage), + objection: hasRole("objection"), + correction: false, + explicit_resolution: hasRole("resolution"), + reopening: hasRole("reopening"), + raises_again: false, + agrees_with_settle: !!scenario.agree, + planner_request: false, + planning_substance: hasRole("reason") || hasRole("constraint") || hasRole("objection") + || (!!scenario.directNewChoice && !triage), + duplicate: false, + material_objection: !!scenario.material, + owned_unretracted: candidate ? scenario.owned?.[Number(candidate[1])] ?? true : false, + }; + let selectedThread = role === "question" ? "new" : thread?.id ?? "none"; + let selectedOption = scenario.directNewChoice && !triage + ? "new" + : ["reason", "support", "objection", "resolution"].includes(role) + ? option?.id ?? "none" + : role === "option" + ? "new" + : "none"; + let selectedRelation = role === "objection" + ? "challenges" + : role === "constraint" + ? "qualifies" + : relevant + ? "supports" + : "unrelated"; + let key = id.replace(/^c\d+_/, ""); + if (question.type === "noul") { + answer[id] = { type: "noul", noul: flags[key] ? 0.97 : 0.03 }; + } else if (question.type === "score") { + let level = key === "significance" + ? scenario.significance ?? (relevant ? 2 : 0) + : key === "commitment" + ? role === "resolution" ? 3 : 0 + : key === "novelty" + ? relevant ? 2 : 0 + : 0; + answer[id] = score(question, level); + } else { + let selected = key === "act" + ? scenario.spikeChoice + ? "proposal" + : role === "question" + ? "question" + : role === "option" + ? "proposal" + : role === "resolution" + ? "commitment" + : role === "none" + ? "other" + : "evaluation" + : key === "role" + ? role + : key === "thread" || key === "thread_target" + ? selectedThread + : key === "option" + ? selectedOption + : key === "chosen_option" + ? role === "resolution" || scenario.spikeChoice || scenario.agree + ? selectedOption + : "none" + : key === "relation" + ? selectedRelation + : "none"; + answer[id] = choice(question, selected); + } + } + return answer; +} + +function validateIsolatedTarget(request: JevRequest, scenario: Scenario): void { + if ("new_question" in request.questions) return; + if ( + scenario.triageOnly + && Object.keys(request.questions).some(key => /^c\d+_(?!owned_unretracted$)/.test(key)) + ) { + throw new Error("mixed fixture targeting must issue one request per candidate"); + } + if (!scenario.target) return; + let state = request.state as { + current?: { text?: string }; + preceding?: string; + recent?: Array<{ text?: string }>; + candidates?: Array<{ quote?: string }>; + }; + let indexes = [ + ...new Set( + Object.keys(request.questions).flatMap(key => { + let match = /^c(\d+)_/.exec(key); + return match ? [Number(match[1])] : []; + }), + ), + ]; + if (indexes.length !== 1) throw new Error("mixed fixture targeting must isolate one candidate"); + let index = indexes[0]!; + if (!Object.keys(request.questions).every(key => key.startsWith(`c${index}_`))) { + throw new Error("mixed fixture targeting leaked another candidate"); + } + if ( + state.current?.text !== state.candidates?.at(-1)?.quote + || state.candidates?.length !== index + 1 + ) { + throw new Error("mixed fixture targeting did not preserve the exact candidate span"); + } + if (scenario.target.preceding && !scenario.target.preceding.includes(state.preceding ?? "")) { + throw new Error("mixed fixture targeting did not retain bounded preceding context"); + } + for (let text of scenario.target.recent ?? []) { + if (!state.recent?.some(entry => entry.text === text)) { + throw new Error("mixed fixture targeting lost conversation history"); + } + } +} + +async function waitForRelease(signal?: AbortSignal): Promise { + let directory = process.env.E2E_JEV_CONTROL_DIR; + if (!directory) throw new Error("Jev fixture control directory is missing"); + while (!(await Bun.file(join(directory, "delayed-question")).exists())) { + if (signal?.aborted) throw signal.reason; + await Bun.sleep(15); + } +} + +let fake = async (input: Parameters[0], init?: Parameters[1]) => { + let url = new URL(input instanceof Request ? input.url : input); + if (url.hostname !== "api.typesafe.ai") return network(input, init); + if (url.href !== "https://api.typesafe.ai/v1/systemone" || init?.method !== "POST") { + return Response.json({ error: "unexpected TypeSafe request" }, { status: 501 }); + } + let headers = new Headers(init.headers); + if (headers.get("authorization") !== "Bearer e2e-jev-only") { + return Response.json({ error: "unexpected TypeSafe credentials" }, { status: 401 }); + } + let request = JSON.parse(String(init.body)) as JevRequest & { model: string }; + let current = (request.state as { current?: { id?: string; text?: string } }).current; + let scenario = current?.text && SCENARIOS[current.text]; + if (!current?.id || !scenario) { + return Response.json({ error: "unknown Jev fixture scenario" }, { status: 501 }); + } + if (scenario.delay) await waitForRelease(init.signal ?? undefined); + try { + validateIsolatedTarget(request, scenario); + } catch (error) { + return Response.json({ + error: error instanceof Error ? error.message : "invalid fixture request", + }, { + status: 422, + }); + } + let failureCount = failures.get(current.id) ?? 0; + if (failureCount < (scenario.failTimes ?? 0)) { + failures.set(current.id, failureCount + 1); + return Response.json({ error: "injected failure" }, { status: 503 }); + } + return Response.json({ + model: "jev-e2e-fixture", + answers: answers(request, scenario), + usage: { input_tokens: 10, output_tokens: 5 }, + }); +}; + +globalThis.fetch = Object.assign(fake, { preconnect: network.preconnect }); diff --git a/e2e/planner-jobs.ts b/e2e/planner-jobs.ts new file mode 100644 index 00000000..ada8b82e --- /dev/null +++ b/e2e/planner-jobs.ts @@ -0,0 +1,27 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { ROOT } from "./servers"; + +type Kind = "heading" | "refine" | "suggest" | "prose"; +type Call = { tool: string; args: Record }; + +/** Test-process-to-server script directory, used only by the conversation-plan project. */ +export const PLANNER_JOBS_DIR = join(ROOT, "e2e", "test-results", "planner-jobs"); + +export async function resetPlannerJobs(): Promise { + await rm(PLANNER_JOBS_DIR, { recursive: true, force: true }); + await mkdir(PLANNER_JOBS_DIR, { recursive: true }); +} + +export async function scriptJob(kind: Kind, calls: Call[], options: { hold?: boolean } = {}) { + await mkdir(PLANNER_JOBS_DIR, { recursive: true }); + await rm(join(PLANNER_JOBS_DIR, `${kind}.release`), { force: true }); + await rm(join(PLANNER_JOBS_DIR, `${kind}.hold`), { force: true }); + await writeFile(join(PLANNER_JOBS_DIR, `${kind}.json`), JSON.stringify(calls)); + if (options.hold) await writeFile(join(PLANNER_JOBS_DIR, `${kind}.hold`), "hold"); +} + +export async function releasePlannerJob(kind: Kind): Promise { + await writeFile(join(PLANNER_JOBS_DIR, `${kind}.release`), "release"); +} diff --git a/e2e/research-offer-fixtures.ts b/e2e/research-offer-fixtures.ts new file mode 100644 index 00000000..097c6b12 --- /dev/null +++ b/e2e/research-offer-fixtures.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { SQL } from "bun"; +import { + actOnResearchOffer, + initialState, + offerResearch, +} from "../apps/server/src/conversation-plan/domain"; + +import type { Chat, ConversationPlan } from "../packages/protocol/index"; + +export type OfferSpec = { + id: string; + brief: string; + status: "accepted" | "offered"; + workspace?: "unlinked" | "linked"; +}; + +function databaseUrl(): string { + return process.env.E2E_DATABASE_URL_0 + ?? "postgresql://chopin:chopin@127.0.0.1:5433/chopin?sslmode=disable"; +} + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function offerSources(specs: readonly OfferSpec[], handle: string) { + let state = initialState(); + let transcript: Chat.Entry[] = []; + for (let [index, spec] of specs.entries()) { + let entry: Chat.Entry = { + id: `source-${spec.id}`, + author: { kind: "member", handle }, + text: spec.brief, + ts: index + 1, + }; + transcript.push(entry); + state = offerResearch(state, { + id: spec.id, + needId: `need-${spec.id}`, + contextId: "context-v1", + brief: entry.text, + source: { + messageId: entry.id, + author: entry.author as Extract, + quote: entry.text, + start: 0, + end: entry.text.length, + }, + }, entry); + if (spec.status === "accepted") { + state = actOnResearchOffer(state, spec.id, { + id: `action-${spec.id}`, + kind: "research", + actor: { kind: "member", handle }, + principalId: `U_${handle}`, + at: index + 10, + }); + } + } + return { state, transcript }; +} + +export async function saveConversationState( + channelId: string, + state: ConversationPlan.State, +): Promise { + let database = new SQL(databaseUrl()); + try { + let [row] = await database<{ sidecar: unknown }[]>` + SELECT sidecar FROM channel_snapshots WHERE channel_id = ${channelId} + `; + if (!row) throw new Error("test channel snapshot was not seeded"); + let previous = typeof row.sidecar === "string" + ? JSON.parse(row.sidecar) as Record + : row.sidecar as Record; + let sidecar = JSON.stringify({ ...previous, conversationPlan: state }); + await database.begin(async transaction => { + await transaction` + UPDATE channel_snapshots SET sidecar = ${sidecar}::jsonb + WHERE channel_id = ${channelId} + `; + await transaction` + UPDATE channel_state SET sidecar = ${sidecar}::jsonb + WHERE channel_id = ${channelId} + `; + }); + } finally { + await database.close(); + } +} + +export async function seedRequest( + channelId: string, + spec: OfferSpec, + stage: "unlinked" | "linked", + handle = "readonly", +): Promise { + let requestedBy = `U_${handle}`; + let originMessageId = `source-${spec.id}`; + let question = spec.brief; + let workspaceId = `workspace-${crypto.randomUUID()}`; + let turnId = crypto.randomUUID(); + let messageId = crypto.randomUUID(); + let jobId = stage === "linked" ? crypto.randomUUID() : undefined; + let targetKey = jobId ? `research-evidence:${turnId}` : undefined; + let now = new Date(); + let idempotencyKey = `research-planner:${digest(originMessageId).slice(0, 48)}`; + let fingerprint = digest(`research-planner\0${ + JSON.stringify({ + channelId, + question, + originMessageId, + requestedBy, + requestedByHandle: handle, + }) + }`); + let database = new SQL(databaseUrl()); + try { + await database.begin(async transaction => { + if (jobId && targetKey) { + await transaction` + INSERT INTO background_job_channels (channel_id, revision) + VALUES (${channelId}, 1) + `; + await transaction` + INSERT INTO background_job_targets (channel_id, target_key, generation) + VALUES (${channelId}, ${targetKey}, 1) + `; + await transaction` + INSERT INTO background_jobs ( + id, channel_id, type, version, origin, target_key, target_generation, + idempotency_key, fingerprint, input, state, revision, attempts, + claim_generation, available_at, created_at, updated_at + ) VALUES ( + ${jobId}, ${channelId}, 'research-evidence', 1, 'planner', ${targetKey}, 1, + ${`e2e-${jobId}`}, ${`fixture-${jobId}`}, + ${JSON.stringify({ workspaceId, turnId, question })}::jsonb, + 'pending', 1, 0, 0, ${now}, ${now}, ${now} + ) + `; + } + await transaction` + INSERT INTO research_workspaces ( + id, channel_id, title, proposed_question, confirmed_query, origin, + origin_message_id, inline_reference, created_by, confirmed_by, + revision, next_turn_ordinal, next_message_sequence, + idempotency_key, fingerprint, created_at, updated_at + ) VALUES ( + ${workspaceId}, ${channelId}, ${question}, ${question}, ${question}, 'planner', + ${originMessageId}, ${stage === "linked" ? "placed" : "pending"}, + ${requestedBy}, ${requestedBy}, 0, 2, 2, + ${idempotencyKey}, ${fingerprint}, ${now}, ${now} + ) + `; + await transaction` + INSERT INTO research_turns ( + id, workspace_id, ordinal, kind, request_id, fingerprint, question, + requested_by, evidence_job_id, created_at, updated_at + ) VALUES ( + ${turnId}, ${workspaceId}, 1, 'initial', ${originMessageId}, ${fingerprint}, + ${question}, ${requestedBy}, ${jobId ?? null}, ${now}, ${now} + ) + `; + await transaction` + INSERT INTO research_messages ( + id, workspace_id, sequence, turn_id, author_kind, user_id, + user_handle, text, created_at + ) VALUES ( + ${messageId}, ${workspaceId}, 1, ${turnId}, 'member', ${requestedBy}, + ${handle}, ${question}, ${now} + ) + `; + }); + } finally { + await database.close(); + } + return workspaceId; +} + +export async function transportState(channelIds: readonly string[]) { + let database = new SQL(databaseUrl()); + try { + let snapshots = []; + for (let channelId of channelIds) { + let [plan] = await database<{ revision: number | null }[]>` + SELECT (sidecar->'conversationPlan'->>'revision')::int AS revision + FROM channel_snapshots WHERE channel_id = ${channelId} + `; + let owners = await database<{ ownerSessionId: string | null; status: string }[]>` + SELECT owner_session_id AS "ownerSessionId", status + FROM agent_state WHERE channel_id = ${channelId} + ORDER BY generation + `; + let jobs = await database<{ id: string; type: string; state: string; revision: number }[]>` + SELECT id, type, state, revision FROM background_jobs + WHERE channel_id = ${channelId} ORDER BY id + `; + let workspaces = await database<{ + id: string; + inlineReference: string | null; + }[]>` + SELECT id, inline_reference AS "inlineReference" FROM research_workspaces + WHERE channel_id = ${channelId} ORDER BY id + `; + let turns = await database<{ + id: string; + evidenceJobId: string | null; + }[]>` + SELECT turns.id, turns.evidence_job_id AS "evidenceJobId" + FROM research_turns AS turns + JOIN research_workspaces AS workspaces ON workspaces.id = turns.workspace_id + WHERE workspaces.channel_id = ${channelId} ORDER BY turns.id + `; + snapshots.push({ channelId, plan, owners, jobs, workspaces, turns }); + } + return snapshots; + } finally { + await database.close(); + } +} + +export async function researchWorkCounts(channelId: string): Promise<{ + jobs: number; + owners: number; + workspaces: number; +}> { + let database = new SQL(databaseUrl()); + try { + let [counts] = await database<{ + jobs: number; + owners: number; + workspaces: number; + }[]>` + SELECT + (SELECT count(*)::int FROM background_jobs WHERE channel_id = ${channelId}) AS jobs, + (SELECT count(*)::int FROM agent_state WHERE channel_id = ${channelId}) AS owners, + (SELECT count(*)::int FROM research_workspaces WHERE channel_id = ${channelId}) AS workspaces + `; + if (!counts) throw new Error("could not read research work counts"); + return counts; + } finally { + await database.close(); + } +} diff --git a/e2e/research-offers-ui.e2e.ts b/e2e/research-offers-ui.e2e.ts new file mode 100644 index 00000000..d1439edb --- /dev/null +++ b/e2e/research-offers-ui.e2e.ts @@ -0,0 +1,408 @@ +import { authenticate, expect, openIsolatedRoom, ready, roomPath, test } from "./room"; +import { seedChildChannel } from "./database"; +import { + offerSources, + researchWorkCounts, + saveConversationState, + seedRequest, +} from "./research-offer-fixtures"; + +import type { Research } from "../packages/protocol/index"; +import type { BrowserContext, Page } from "@playwright/test"; +import type { OfferSpec } from "./research-offer-fixtures"; + +type SocketFrame = { kind: string; [key: string]: unknown }; + +function port(baseURL: string): number { + return Number(new URL(baseURL).port); +} + +function offerCard(page: Page, offerId: string) { + return page.locator(`[data-chat-message-id="source-${offerId}"]`) + .getByRole("group", { name: "Research suggestion" }); +} + +async function routeResearchNotification( + context: BrowserContext, + holdInitialLinkResponse = true, +): Promise<{ + frames: SocketFrame[]; + initialLinkHeld: () => boolean; + resumeReplyHeld: () => boolean; + notify: (workspaceId: string) => void; + releaseInitialLink: () => void; + failHeldResume: () => void; +}> { + // These test-only timing controls exercise the real observer and a delayed failure handler; + // neither frame is evidence of research publication. + let frames: SocketFrame[] = []; + let send: ((frame: unknown) => void) | undefined; + let initialLinkReply: string | undefined; + let initialLinkCaptured = false; + let resumeRequestId: string | undefined; + let heldResumeReply = false; + let failResume: ((frame: { kind: string; rid: string; message: string }) => void) | undefined; + await context.routeWebSocket("**/ws?**", route => { + let server = route.connectToServer(); + send = frame => route.send(JSON.stringify(frame)); + route.onMessage(message => { + if (typeof message === "string") { + try { + let frame = JSON.parse(message) as SocketFrame; + if (frame.kind === "conversation-plan:research" && frame.choice === "resume") { + resumeRequestId = typeof frame.rid === "string" ? frame.rid : undefined; + } + } catch { + // Preserve unrelated binary or non-JSON wire frames. + } + } + server.send(message); + }); + server.onMessage(message => { + if (typeof message === "string") { + try { + let frame = JSON.parse(message) as SocketFrame; + frames.push(frame); + if ( + holdInitialLinkResponse + && frame.kind === "conversation-plan:research-link" + && !initialLinkCaptured + ) { + initialLinkCaptured = true; + initialLinkReply = message; + return; + } + if (typeof frame.rid === "string" && frame.rid === resumeRequestId) { + heldResumeReply = true; + return; + } + } catch { + // Preserve unrelated binary or non-JSON wire frames. + } + } + route.send(message); + }); + failResume = frame => route.send(JSON.stringify(frame)); + }); + return { + frames, + initialLinkHeld: () => initialLinkReply !== undefined, + resumeReplyHeld: () => heldResumeReply, + notify(workspaceId) { + if (!send) throw new Error("research notification route is not connected"); + send({ kind: "research:changed", ts: 0, revision: 1, workspaceId }); + }, + releaseInitialLink() { + if (!initialLinkReply) throw new Error("initial research-link reply is not held"); + let reply = initialLinkReply; + initialLinkReply = undefined; + if (!send) throw new Error("research notification route is not connected"); + // The route forwards this held correlated reply as a test-only wire frame. + send(JSON.parse(reply)); + }, + failHeldResume() { + if (!resumeRequestId || !heldResumeReply || !failResume) { + throw new Error("resume reply is not held"); + } + failResume({ + kind: "session:error", + rid: resumeRequestId, + message: "synthetic delayed failure after link", + }); + }, + }; +} + +test("writers can accept or dismiss source-attached offers; viewers only read them", async ({ baseURL, browser, context, join, room, seed }) => { + let specs: OfferSpec[] = [ + { + id: "ui-accept", + brief: "Review this synthetic cache-retention request exactly as written.", + status: "offered", + }, + { + id: "ui-dismiss", + brief: "Consider this synthetic maintenance-window request.", + status: "offered", + }, + ]; + let { state, transcript } = offerSources(specs, "source-author"); + await seed("# Research offer controls fixture\n", { transcript }); + await saveConversationState(room, state); + let writerCountsBefore = await researchWorkCounts(room); + expect(writerCountsBefore).toEqual({ jobs: 0, owners: 0, workspaces: 0 }); + let socket = await routeResearchNotification(context, false); + let writerA = await join("writer-a"); + let acceptedCardA = offerCard(writerA, "ui-accept"); + let sourceMessageA = writerA.locator('[data-chat-message-id="source-ui-accept"]'); + await expect(sourceMessageA).toContainText(specs[0]!.brief); + await expect(acceptedCardA.getByText(specs[0]!.brief, { exact: true })).toBeVisible(); + await expect(acceptedCardA.getByRole("button", { name: "Research", exact: true })) + .toBeVisible(); + await expect(acceptedCardA.getByRole("button", { name: "Dismiss", exact: true })) + .toBeVisible(); + expect(await researchWorkCounts(room)).toEqual(writerCountsBefore); + + let researchButton = acceptedCardA.getByRole("button", { name: "Research", exact: true }); + await researchButton.focus(); + await researchButton.press("Enter"); + await expect.poll(() => + socket.frames.some(frame => + frame.kind === "conversation-plan:research" + && frame.offerId === "ui-accept" + && frame.status === "accepted" + ) + ).toBe(true); + let acceptedReply = socket.frames.find(frame => + frame.kind === "conversation-plan:research" && frame.offerId === "ui-accept" + )!; + expect(acceptedReply.execution).toBe("pending-retry"); + await expect(acceptedCardA).toContainText("Research accepted"); + let countsAfterAcceptance = await researchWorkCounts(room); + expect(countsAfterAcceptance.jobs).toBe(0); + expect(countsAfterAcceptance.workspaces).toBe(0); + await expect.poll(() => + socket.frames.some(frame => + frame.kind === "conversation-plan:research-link" + && frame.offerId === "ui-accept" + && frame.status === "pending" + ) + ).toBe(true); + + let writerB = await join("writer-b"); + let acceptedCardB = offerCard(writerB, "ui-accept"); + await expect(acceptedCardB).toContainText("Research accepted"); + await expect(acceptedCardB.getByRole("button", { name: "Resume", exact: true })).toBeVisible(); + await writerB.reload(); + await ready(writerB); + acceptedCardB = offerCard(writerB, "ui-accept"); + await expect(acceptedCardB).toContainText("Research accepted"); + await expect(acceptedCardB.getByRole("button", { name: "Resume", exact: true })).toBeVisible(); + + let countsBeforeDismiss = await researchWorkCounts(room); + let dismissedCardA = offerCard(writerA, "ui-dismiss"); + let dismissButton = dismissedCardA.getByRole("button", { name: "Dismiss", exact: true }); + await dismissButton.focus(); + await dismissButton.press("Space"); + await expect(dismissedCardA).toContainText("Dismissed"); + expect(await researchWorkCounts(room)).toEqual(countsBeforeDismiss); + await writerB.reload(); + await ready(writerB); + let dismissedCardB = offerCard(writerB, "ui-dismiss"); + await expect(dismissedCardB).toContainText("Dismissed"); + await expect(dismissedCardB.getByRole("button", { name: "Research", exact: true })) + .toHaveCount(0); + await expect(dismissedCardB.getByRole("button", { name: "Dismiss", exact: true })) + .toHaveCount(0); + await expect(offerCard(writerB, "ui-accept")).toContainText("Research accepted"); + + let viewerContext = await browser.newContext({ baseURL }); + try { + let readonly = await viewerContext.newPage(); + await authenticate(readonly, "readonly", baseURL!); + await readonly.goto(roomPath(room)); + for (let offerId of ["ui-accept", "ui-dismiss"]) { + let card = offerCard(readonly, offerId); + await expect(card).toBeVisible(); + await expect(card.getByRole("button", { name: "Research", exact: true })) + .toHaveCount(0); + await expect(card.getByRole("button", { name: "Dismiss", exact: true })) + .toHaveCount(0); + await expect(card.getByRole("button", { name: "Resume", exact: true })) + .toHaveCount(0); + } + await readonly.reload(); + await expect(offerCard(readonly, "ui-accept")).toContainText("Research accepted"); + await expect(offerCard(readonly, "ui-dismiss")).toContainText("Dismissed"); + } finally { + await viewerContext.close(); + } +}); + +test("an accepted link arrives from the room event, reloads, and opens its ready child", async ({ baseURL, browser, room, seed }) => { + let spec: OfferSpec = { + id: "ui-link-ready", + brief: "Inspect this synthetic accepted research request.", + status: "accepted", + }; + let handle = "link-writer"; + let { state, transcript } = offerSources([spec], handle); + await seed("# Research offer linked-state fixture\n", { transcript }); + await saveConversationState(room, state); + let socket: Awaited> | undefined; + let isolated = await openIsolatedRoom( + browser, + baseURL!, + room, + handle, + {}, + async context => { + socket = await routeResearchNotification(context); + }, + ); + try { + let page = isolated.page; + let card = offerCard(page, spec.id); + await expect(card).toContainText("Research accepted"); + await expect.poll(() => socket?.initialLinkHeld()).toBe(true); + await expect(card.getByRole("button", { name: "Resume", exact: true })).toHaveCount(0); + socket!.releaseInitialLink(); + await expect.poll(() => + socket?.frames.some(frame => + frame.kind === "conversation-plan:research-link" + && frame.offerId === spec.id + && frame.status === "pending" + ) + ).toBe(true); + await expect(card.getByRole("button", { name: "Resume", exact: true })).toBeVisible(); + await card.getByRole("button", { name: "Resume", exact: true }).click(); + await expect.poll(() => socket?.resumeReplyHeld()).toBe(true); + + let workspaceId = await seedRequest(room, spec, "linked", handle); + let child = await seedChildChannel( + port(baseURL!), + room, + crypto.randomUUID(), + "Synthetic ready research child", + "# Synthetic report\n\nThis is a transport fixture, not published research.\n", + ); + let requestReads = 0; + let readyView: Research.RequestView = { + id: workspaceId, + channelId: room, + question: spec.brief, + sources: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: "completed", + stage: "ready", + child: { + id: child.id, + slug: child.slug, + sourceCount: 0, + summary: "Synthetic ready-child fixture.", + title: "Synthetic ready research child", + }, + }; + // The ID/link is read from the real server; this scripted ready response and child are + // mechanical store/navigation fixtures, not evidence of research publication. + await page.route(`**/api/channels/${room}/research-requests/**`, async route => { + let request = route.request(); + let path = new URL(request.url()).pathname; + if ( + request.method() === "GET" + && path === `/api/channels/${room}/research-requests/${workspaceId}` + ) { + requestReads++; + await route.fulfill({ json: readyView }); + return; + } + await route.continue(); + }); + if (!socket) throw new Error("research notification route was not installed"); + socket.notify(workspaceId); + await expect.poll(() => + socket?.frames.some(frame => + frame.kind === "conversation-plan:research-link" + && frame.offerId === spec.id + && frame.status === "linked" + && frame.researchRequestId === workspaceId + ) + ).toBe(true); + await expect(card.getByText("Research ready", { exact: true })).toBeVisible(); + await expect(card.getByRole("button", { name: "Open research", exact: true })) + .toBeVisible(); + await expect.poll(() => requestReads).toBeGreaterThan(0); + await expect(card.getByRole("button", { name: "Resume", exact: true })).toHaveCount(0); + await expect(card).toHaveAttribute("aria-busy", "true"); + // The actual Resume request reached the server; only its reply is delayed until the + // authoritative linked state has rendered, then a test-only error exercises the race. + socket!.failHeldResume(); + await expect(card).toHaveAttribute("aria-busy", "false"); + await expect(card.getByRole("alert")).toHaveCount(0); + await expect(card.getByText("Research ready", { exact: true })).toBeVisible(); + + await page.reload(); + await ready(page); + card = offerCard(page, spec.id); + await expect(card.getByText("Research ready", { exact: true })).toBeVisible(); + await expect.poll(() => requestReads).toBeGreaterThan(1); + await card.getByRole("button", { name: "Open research", exact: true }).click(); + await expect(page).toHaveURL(`${baseURL}${child.path}`); + } finally { + await isolated.close(); + } +}); + +test( + "a read-only viewer can retry an exhausted link check without starting research", + async ({ baseURL, browser, room, seed }, testInfo) => { + let spec: OfferSpec = { + id: "ui-retry-link", + brief: "Inspect the existing research link.", + status: "accepted", + }; + let { state, transcript } = offerSources([spec], "source-author"); + await seed("# Research link retry fixture\n", { transcript }); + await saveConversationState(room, state); + let before = await researchWorkCounts(room); + let viewer = await browser.newContext({ baseURL }); + let linkReads = 0; + let linkReplies = 0; + let researchActions = 0; + try { + await viewer.routeWebSocket("**/ws?**", route => { + let server = route.connectToServer(); + route.onMessage(message => { + if (typeof message === "string") { + let frame = JSON.parse(message) as SocketFrame; + if (frame.kind === "conversation-plan:research") researchActions++; + if (frame.kind === "conversation-plan:research-link") { + linkReads++; + if (linkReads <= 4) { + route.send(JSON.stringify({ + kind: "session:error", + rid: frame.rid, + message: "scripted temporary link read failure", + })); + return; + } + } + } + server.send(message); + }); + server.onMessage(message => { + if (typeof message === "string") { + let frame = JSON.parse(message) as SocketFrame; + if (frame.kind === "conversation-plan:research-link") linkReplies++; + } + route.send(message); + }); + }); + let page = await viewer.newPage(); + await page.clock.install(); + await authenticate(page, "readonly", baseURL!); + await page.goto(roomPath(room)); + let card = offerCard(page, spec.id); + await expect(card).toBeVisible(); + await expect.poll(() => linkReads).toBe(1); + await page.clock.pauseAt(Date.now() + 100); + for (let [delay, count] of [[2_000, 2], [5_000, 3], [10_000, 4]] as const) { + await page.clock.runFor(delay); + await expect.poll(() => linkReads).toBe(count); + } + let retry = card.getByRole("button", { name: "Retry link check", exact: true }); + await expect(retry).toBeVisible(); + await expect(card.getByRole("button", { name: "Resume", exact: true })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("research-link-retry.png") }); + await retry.click(); + await expect.poll(() => linkReads).toBe(5); + await expect.poll(() => linkReplies).toBe(1); + await expect(retry).toHaveCount(0); + expect(researchActions).toBe(0); + expect(await researchWorkCounts(room)).toEqual(before); + } finally { + await viewer.close(); + } + }, +); diff --git a/e2e/research-offers.e2e.ts b/e2e/research-offers.e2e.ts new file mode 100644 index 00000000..d16c9828 --- /dev/null +++ b/e2e/research-offers.e2e.ts @@ -0,0 +1,200 @@ +/** Read accepted research-offer links over an admitted, read-only socket. */ + +import { authenticate, expect, test } from "./room"; +import { createChannel } from "./database"; +import { + offerSources, + saveConversationState, + seedRequest, + transportState, +} from "./research-offer-fixtures"; + +import type { OfferSpec } from "./research-offer-fixtures"; + +import type { Page } from "@playwright/test"; + +type WireFrame = { kind: string; rid?: string; [key: string]: unknown }; +type Wire = { socket: WebSocket }; + +function port(baseURL: string): number { + return Number(new URL(baseURL).port); +} +async function connectReader(page: Page, channelId: string): Promise { + await page.evaluate(async id => { + let target = window as typeof window & { __researchLinkWire?: Wire }; + target.__researchLinkWire?.socket.close(); + let url = new URL("/ws", location.href); + url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("channel", id); + let socket = new WebSocket(url); + target.__researchLinkWire = { socket }; + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("reader socket failed")), { + once: true, + }); + }); + }, channelId); + let opened = await wireRequest(page, { kind: "plan:open" }); + expect(opened.kind).toBe("plan:open"); +} + +async function wireRequest(page: Page, frame: WireFrame): Promise { + return page.evaluate(async input => { + let socket = (window as typeof window & { __researchLinkWire?: Wire }) + .__researchLinkWire?.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("research link test socket is not open"); + } + let rid = crypto.randomUUID(); + return await new Promise((resolve, reject) => { + let timeout = setTimeout(() => { + socket.removeEventListener("message", onMessage); + reject(new Error(`no reply for ${input.kind}`)); + }, 10_000); + let onMessage = (event: MessageEvent) => { + if (typeof event.data !== "string") return; + let reply = JSON.parse(event.data) as WireFrame; + if (reply.rid !== rid) return; + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + resolve(reply); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({ ...input, rid, ts: 0 })); + }); + }, frame); +} + +async function seededReader(page: Page, handle: string, baseURL: string, channelId: string) { + await authenticate(page, handle, baseURL); + let session = await page.goto(`${baseURL}/api/session`); + expect(session?.status()).toBe(200); + await connectReader(page, channelId); +} + +test("accepted research-link reads are channel-scoped and read-only for a pull viewer", async ({ baseURL, page, room, seed }) => { + let origin = baseURL!; + let databasePort = port(origin); + let foreignChannel = crypto.randomUUID(); + await createChannel(databasePort, foreignChannel); + let specs: OfferSpec[] = [ + { id: "offer-pending", brief: "Compare synthetic backup targets.", status: "accepted" }, + { + id: "offer-unlinked", + brief: "Review synthetic queue storage.", + status: "accepted", + workspace: "unlinked", + }, + { + id: "offer-linked", + brief: "Review synthetic notification services.", + status: "accepted", + workspace: "linked", + }, + { + id: "offer-offered", + brief: "This offer has not been accepted.", + status: "offered", + }, + { + id: "offer-foreign", + brief: "This request exists only in another channel.", + status: "accepted", + }, + ]; + let { state, transcript } = offerSources(specs, "readonly"); + await authenticate(page, "readonly", origin); + await seed("# Research link fixture\n", { transcript }); + await saveConversationState(room, state); + let seededIds = new Map(); + for (let spec of specs) { + if (spec.workspace) { + seededIds.set(spec.id, await seedRequest(room, spec, spec.workspace)); + } + } + let foreignId = await seedRequest( + foreignChannel, + specs.find(spec => spec.id === "offer-foreign")!, + "unlinked", + ); + let session = await page.goto(`${origin}/api/session`); + expect(session?.status()).toBe(200); + await connectReader(page, room); + let before = await transportState([room, foreignChannel]); + + let pending = await wireRequest(page, { + kind: "conversation-plan:research-link", + offerId: "offer-pending", + }); + expect(pending).toMatchObject({ + kind: "conversation-plan:research-link", + offerId: "offer-pending", + status: "pending", + }); + expect(pending.researchRequestId).toBeUndefined(); + + for (let [offerId, status] of [["offer-unlinked", "unlinked"], ["offer-linked", "linked"]]) { + let reply = await wireRequest(page, { + kind: "conversation-plan:research-link", + offerId, + }); + expect(reply).toMatchObject({ + kind: "conversation-plan:research-link", + offerId, + status, + researchRequestId: seededIds.get(offerId), + }); + } + + let foreign = await wireRequest(page, { + kind: "conversation-plan:research-link", + offerId: "offer-foreign", + }); + expect(foreign).toMatchObject({ + kind: "conversation-plan:research-link", + offerId: "offer-foreign", + status: "pending", + }); + expect(foreign.researchRequestId).toBeUndefined(); + expect(JSON.stringify(foreign)).not.toContain(foreignId); + + for (let offerId of ["offer-offered", "offer-absent"]) { + let rejected = await wireRequest(page, { + kind: "conversation-plan:research-link", + offerId, + }); + expect(rejected).toMatchObject({ kind: "session:error" }); + expect(rejected).not.toHaveProperty("researchRequestId"); + } + let after = await transportState([room, foreignChannel]); + expect(after).toEqual(before); +}); + +test("research-link rechecks a pull viewer after GitHub access is revoked", async ({ baseURL, page, room, seed }) => { + let origin = baseURL!; + let spec: OfferSpec = { + id: "offer-revoked", + brief: "Check a synthetic permission revalidation.", + status: "accepted", + }; + let handle = `revoked-after-admission-${crypto.randomUUID().slice(0, 8)}`; + let { state, transcript } = offerSources([spec], handle); + await seed("# Research link authorization fixture\n", { transcript }); + await saveConversationState(room, state); + await seededReader(page, handle, origin, room); + + let invalidated = await page.request.get(`${origin}/auth/github/setup`, { + maxRedirects: 0, + }); + expect(invalidated.status()).toBe(303); + let rejected = await wireRequest(page, { + kind: "conversation-plan:research-link", + offerId: spec.id, + }); + expect(rejected).toMatchObject({ + kind: "session:error", + message: "authorization expired", + }); + expect(rejected).not.toHaveProperty("researchRequestId"); +}); diff --git a/e2e/sidecar-navigation.e2e.ts b/e2e/sidecar-navigation.e2e.ts new file mode 100644 index 00000000..3aa6c293 --- /dev/null +++ b/e2e/sidecar-navigation.e2e.ts @@ -0,0 +1,509 @@ +/** Browser coverage for prose navigation around hidden discarded cards. */ + +import { content, expect, test } from "./room"; +import { storedQuestion } from "../apps/server/src/testing/plan"; + +import type { Page } from "@playwright/test"; + +const WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; +const OPEN_DEFINITION = { + questions: [{ + id: QUESTION, + header: "Rollout", + question: "How should we deploy?", + multiple: false, + options: [{ id: OPTION, label: "Canary", description: "" }], + }], +}; +const DISCARDED_CARD = ` + + +`; +const DISCARDED_RECORD = { + id: WIDGET, + definition: OPEN_DEFINITION, + status: "discarded", + answers: { [QUESTION]: "Canary" }, + choices: [OPTION], + resolver: "ana", + owner: "ana", + decidedAt: 1_790_000_000, + history: [], + optionOrigins: {}, + editors: [], +}; +const OPEN_CARD = ` + + +`; + +function discardedHost(page: Page) { + return content(page).locator(`[data-plan-questionnaire="${WIDGET}"]`); +} + +async function placeCaretAtEnd(page: Page, sentence: string) { + return content(page).evaluate((root, before) => { + let paragraph = [...root.querySelectorAll("p")].find(item => item.textContent === before); + let text = paragraph?.querySelector("[data-lexical-text]")?.firstChild; + if (!(text instanceof Text)) throw new Error("the target paragraph has no text node"); + let selection = getSelection(); + if (!selection) throw new Error("selection unavailable"); + (root as HTMLElement).focus(); + let range = document.createRange(); + range.setStart(text, text.data.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return { + active: document.activeElement === root, + anchor: selection.anchorNode === text, + paragraph: paragraph?.textContent ?? null, + contentEditable: (root as HTMLElement).contentEditable, + }; + }, sentence); +} + +async function placeCaretAtStart(page: Page, sentence: string) { + return content(page).evaluate((root, textContent) => { + let paragraph = [...root.querySelectorAll("p")].find(item => item.textContent === textContent); + let text = paragraph?.querySelector("[data-lexical-text]")?.firstChild; + if (!(text instanceof Text)) throw new Error("the target paragraph has no text node"); + let selection = getSelection(); + if (!selection) throw new Error("selection unavailable"); + (root as HTMLElement).focus(); + let range = document.createRange(); + range.setStart(text, 0); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return { + active: document.activeElement === root, + anchor: selection.anchorNode === text, + paragraph: paragraph?.textContent ?? null, + contentEditable: (root as HTMLElement).contentEditable, + }; + }, sentence); +} + +async function caretState(page: Page) { + return page.evaluate(() => { + let selection = getSelection(); + let node = selection?.anchorNode; + let element = node instanceof Element ? node : node?.parentElement; + let active = document.activeElement; + return { + active: active?.tagName ?? null, + activeContentEditable: active instanceof HTMLElement ? active.contentEditable : null, + anchorNode: node?.nodeName ?? null, + anchorText: node?.textContent ?? null, + offset: selection?.anchorOffset ?? null, + paragraph: element?.closest("p")?.textContent ?? null, + }; + }); +} + +test("arrowing between ordinary paragraphs moves to the next paragraph", async ({ join, seed }) => { + await seed("Before hidden decision.\n\nAfter hidden decision."); + let page = await join("ana"); + let editor = content(page); + let originalParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + let initial = await placeCaretAtEnd(page, "Before hidden decision."); + expect(initial).toEqual({ + active: true, + anchor: true, + paragraph: "Before hidden decision.", + contentEditable: "true", + }); + await page.keyboard.press("ArrowRight"); + let caret = await caretState(page); + let afterRightParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("X"); + let afterRightTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + + initial = await placeCaretAtStart(page, "After hidden decision."); + expect(initial.paragraph).toBe("After hidden decision."); + await page.keyboard.press("ArrowLeft"); + let afterLeftParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("Y"); + let afterLeftTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + let restoredParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + if ( + caret.paragraph !== "After hidden decision." + || JSON.stringify(afterRightParagraphs) !== JSON.stringify(originalParagraphs) + || !afterRightTyping.includes("XAfter hidden decision.") + || JSON.stringify(afterLeftParagraphs) !== JSON.stringify(originalParagraphs) + || !afterLeftTyping.includes("Before hidden decision.Y") + || JSON.stringify(restoredParagraphs) !== JSON.stringify(originalParagraphs) + ) { + throw new Error(`Ordinary paragraph traversal: ${ + JSON.stringify({ + caret, + afterRightParagraphs, + afterRightTyping, + afterLeftParagraphs, + afterLeftTyping, + restoredParagraphs, + }) + }`); + } +}); + +test("a hidden discarded node has no layout footprint or keyboard stop", async ({ join, seed }) => { + await seed(`Before hidden decision.\n\n${DISCARDED_CARD}\n\nAfter hidden decision.`, { + questions: [DISCARDED_RECORD], + }); + let page = await join("ana"); + let editor = content(page); + let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); + await expect(host).toBeAttached(); + await expect(host).toBeHidden(); + let documentOrder = await editor.evaluate((root, widgetId) => { + let paragraphs = [...root.querySelectorAll("p")]; + let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); + return { + hiddenBetweenProse: Boolean( + paragraphs[0] && hidden && paragraphs.at(-1) + && paragraphs[0]!.compareDocumentPosition(hidden) & Node.DOCUMENT_POSITION_FOLLOWING + && hidden.compareDocumentPosition(paragraphs.at(-1)!) + & Node.DOCUMENT_POSITION_FOLLOWING, + ), + }; + }, WIDGET); + expect(documentOrder.hiddenBetweenProse).toBe(true); + let layout = await host.evaluate(element => { + let style = getComputedStyle(element); + let bounds = element.getBoundingClientRect(); + return { + display: style.display, + marginBottom: style.marginBottom, + marginTop: style.marginTop, + width: bounds.width, + height: bounds.height, + }; + }); + expect(layout).toEqual({ + display: "none", + marginBottom: "0px", + marginTop: "0px", + width: 0, + height: 0, + }); + await expect(host.locator("button, input, select, textarea, [tabindex]:not([tabindex='-1'])")) + .toHaveCount(0); + let initial = await placeCaretAtStart(page, "After hidden decision."); + expect(initial).toEqual({ + active: true, + anchor: true, + paragraph: "After hidden decision.", + contentEditable: "true", + }); + let originalParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + initial = await placeCaretAtEnd(page, "Before hidden decision."); + await page.keyboard.press("ArrowRight"); + let afterNaturalRight = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("ArrowLeft"); + let afterNaturalLeft = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("R"); + let afterNaturalLeftTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + let afterNaturalRestore = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + + initial = await placeCaretAtStart(page, "After hidden decision."); + await page.keyboard.press("ArrowLeft"); + let afterLeft = await caretState(page); + let afterLeftParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("Y"); + let afterLeftTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + let afterLeftRestore = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + + initial = await placeCaretAtEnd(page, "Before hidden decision."); + expect(initial.paragraph).toBe("Before hidden decision."); + await page.keyboard.press("ArrowRight"); + let afterRight = await caretState(page); + let afterRightParagraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("X"); + let paragraphs = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + if ( + JSON.stringify(afterNaturalRight) !== JSON.stringify(originalParagraphs) + || JSON.stringify(afterNaturalLeft) !== JSON.stringify(originalParagraphs) + || !afterNaturalLeftTyping.includes("Before hidden decision.R") + || JSON.stringify(afterNaturalRestore) !== JSON.stringify(originalParagraphs) + || JSON.stringify(afterLeftParagraphs) !== JSON.stringify(originalParagraphs) + || JSON.stringify(afterRightParagraphs) !== JSON.stringify(originalParagraphs) + || !afterLeftTyping.includes("Before hidden decision.Y") + || JSON.stringify(afterLeftRestore) !== JSON.stringify(originalParagraphs) + || !paragraphs.includes("XAfter hidden decision.") + ) { + throw new Error(`Discarded traversal state: ${ + JSON.stringify({ + afterNaturalRight, + afterNaturalLeft, + afterNaturalLeftTyping, + afterNaturalRestore, + afterLeft, + afterLeftParagraphs, + afterLeftTyping, + afterLeftRestore, + afterRight, + afterRightParagraphs, + paragraphs, + }) + }`); + } +}); + +test("a discarded first block does not create a prose gap before the first paragraph", async ({ join, seed }) => { + await seed(`${DISCARDED_CARD}\n\nOnly visible paragraph.`, { + questions: [DISCARDED_RECORD], + }); + let page = await join("ana"); + let editor = content(page); + let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); + await expect(host).toBeHidden(); + let order = await editor.evaluate((root, widgetId) => { + let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); + let visible = [...root.querySelectorAll("p")].find( + paragraph => paragraph.textContent === "Only visible paragraph.", + ); + return Boolean( + hidden && visible + && hidden.compareDocumentPosition(visible) & Node.DOCUMENT_POSITION_FOLLOWING, + ); + }, WIDGET); + expect(order).toBe(true); + let original = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await placeCaretAtStart(page, "Only visible paragraph."); + await page.keyboard.press("ArrowLeft"); + let afterArrow = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("X"); + let afterTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + let restored = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + if ( + JSON.stringify(afterArrow) !== JSON.stringify(original) + || !afterTyping.includes("XOnly visible paragraph.") + || JSON.stringify(restored) !== JSON.stringify(original) + ) { + throw new Error(`Discarded leading block traversal: ${ + JSON.stringify({ + original, + afterArrow, + afterTyping, + restored, + }) + }`); + } +}); + +test("a discarded final block routes Right into the existing blank continuation", async ({ join, seed }) => { + await seed(`Only visible paragraph.\n\n${DISCARDED_CARD}`, { + questions: [{ ...DISCARDED_RECORD, status: "discarded", resolver: "ana" }], + }); + let page = await join("ana"); + let editor = content(page); + let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); + await expect(host).toBeHidden(); + let original = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + let order = await editor.evaluate((root, widgetId) => { + let paragraphs = [...root.querySelectorAll("p")]; + let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); + return Boolean( + paragraphs[0] && paragraphs.at(-1) && hidden + && paragraphs[0]!.compareDocumentPosition(hidden) & Node.DOCUMENT_POSITION_FOLLOWING + && hidden.compareDocumentPosition(paragraphs.at(-1)!) + & Node.DOCUMENT_POSITION_FOLLOWING + && paragraphs.at(-1)!.textContent === "", + ); + }, WIDGET); + expect(order).toBe(true); + let initial = await placeCaretAtEnd(page, "Only visible paragraph."); + await page.keyboard.press("ArrowRight"); + let afterArrowState = await caretState(page); + let afterArrow = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.type("X"); + let afterTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await page.keyboard.press("Backspace"); + let restored = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + if ( + JSON.stringify(afterArrow) !== JSON.stringify(original) + || afterTyping.length !== original.length + || afterTyping.at(-1) !== "X" + || JSON.stringify(restored) !== JSON.stringify(original) + ) { + throw new Error(`Discarded trailing block traversal: ${ + JSON.stringify({ + initial, + afterArrowState, + original, + afterArrow, + afterTyping, + restored, + }) + }`); + } +}); + +test("metadata-only discard hides a stale node and routes Right into the next prose block", async ({ join, page, seed }) => { + await seed(`Before metadata discard.\n\n${OPEN_CARD}\n\nAfter metadata discard.`, { + revision: 1, + questions: [{ + id: WIDGET, + definition: OPEN_DEFINITION, + status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }], + openQuestions: [{ + id: WIDGET, + definition: OPEN_DEFINITION, + model: storedQuestion(OPEN_DEFINITION), + revision: 0, + widget: WIDGET, + }], + }); + let holdPlanUpdates = false; + let heldUpdates: string[] = []; + let didHoldUpdate!: () => void; + let updateHeld = new Promise(resolve => didHoldUpdate = resolve); + let didDiscard!: () => void; + let discarded = new Promise(resolve => didDiscard = resolve); + let serverSender: { send: (message: string) => void } | undefined; + await page.routeWebSocket("**/ws?**", route => { + let server = route.connectToServer(); + serverSender = { send: message => route.send(message) }; + route.onMessage(message => server.send(message)); + server.onMessage(message => { + if (typeof message !== "string") return route.send(message); + let frame = JSON.parse(message) as { + kind?: string; + id?: string; + meta?: { status?: string }; + }; + if (holdPlanUpdates && frame.kind === "plan:update") { + heldUpdates.push(message); + didHoldUpdate(); + return; + } + if ( + frame.id === WIDGET && frame.kind === "question:meta" && frame.meta?.status === "discarded" + ) { + didDiscard(); + } + route.send(message); + }); + }); + + let ben = await join("ben"); + let ana = await join("ana"); + let host = discardedHost(ben); + await expect(host).toBeVisible(); + holdPlanUpdates = true; + await ana.getByRole("button", { name: /^Decisions/ }).click(); + let anaCard = ana.locator( + `[data-document-view="decisions"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, + ); + await anaCard.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(anaCard.getByText("Discard this decision?", { exact: true })).toBeVisible(); + await anaCard.getByRole("button", { name: "Discard", exact: true }).click(); + await Promise.all([updateHeld, discarded]); + + try { + await expect(host).toBeHidden(); + let editor = content(ben); + let original = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + let initial = await placeCaretAtEnd(ben, "Before metadata discard."); + await ben.keyboard.press("ArrowRight"); + let afterArrow = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await ben.keyboard.type("X"); + let afterTyping = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + await ben.keyboard.press("Backspace"); + let restored = await editor.evaluate(root => + [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) + ); + if ( + !initial.anchor + || JSON.stringify(afterArrow) !== JSON.stringify(original) + || !afterTyping.includes("XAfter metadata discard.") + || JSON.stringify(restored) !== JSON.stringify(original) + || heldUpdates.length === 0 + ) { + throw new Error(`Metadata-only discarded navigation: ${ + JSON.stringify({ + original, + afterArrow, + afterTyping, + restored, + heldUpdateCount: heldUpdates.length, + }) + }`); + } + } finally { + if (serverSender) { + for (let update of heldUpdates) serverSender.send(update); + } + heldUpdates = []; + } +}); diff --git a/e2e/source/analysis-host-linked.native.ts b/e2e/source/analysis-host-linked.native.ts new file mode 100644 index 00000000..704bd64c --- /dev/null +++ b/e2e/source/analysis-host-linked.native.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { analysis, load, message, prepareAnalysisHost } from "./analysis-host-native"; + +test.beforeAll(prepareAnalysisHost); + +test( + "linked sources remain keyboard navigable from diagnostics", + async ({ page }) => { + await page.setViewportSize({ width: 893, height: 850 }); + let errors = await load(page); + await page.evaluate(() => window.analysisHostFixture.linked(true)); + let anchor = message(page, "host-review"); + await anchor.scrollIntoViewIfNeeded(); + let diagnosticsButton = anchor.getByRole("button", { name: /^Analysis for message:/ }); + await diagnosticsButton.press("Enter"); + let panel = analysis(page, "host-review"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("group", { name: "Linked decisions", exact: true })) + .toBeVisible(); + let cardLinks = panel.locator("[data-card-link]"); + await expect(cardLinks).toHaveCount(3); + await expect(cardLinks.nth(0)).toHaveText("Question"); + await expect(cardLinks.nth(1)).toHaveText("Proposal"); + await expect(cardLinks.nth(2)).toHaveText("Reason"); + await expect(panel.getByRole("button", { name: /^Constraint:/ })).toHaveCount(0); + await expect(panel.getByRole("button", { name: "Add another excerpt", exact: true })) + .toBeVisible(); + await page.keyboard.press("Escape"); + await diagnosticsButton.press("Enter"); + + let proposal = panel.getByRole("button", { name: /^Proposal: show card for/ }); + await proposal.focus(); + await expect(proposal).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(panel).toHaveCount(0); + expect(await page.evaluate(() => window.analysisHostFixture.cards)).toEqual(["host-thread"]); + + expect(errors).toEqual([]); + }, +); + +test( + "read-only diagnostics allow navigation but prevent correction and retries", + async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + let errors = await load(page, "readonly"); + await page.evaluate(() => window.analysisHostFixture.linked(true)); + let anchor = message(page, "host-review"); + await anchor.scrollIntoViewIfNeeded(); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + let panel = analysis(page, "host-review"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("group", { name: "Linked decisions", exact: true })) + .toBeVisible(); + await expect(panel.getByRole("button", { name: "Add another excerpt", exact: true })) + .toBeDisabled(); + await expect(panel.getByRole("button", { name: "Add excerpt", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + + let retry = message(page, "host-retry"); + await retry.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + await expect( + analysis(page, "host-retry").getByRole("button", { + name: "Retry analysis", + exact: true, + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + let jobs = message(page, "host-jobs"); + await jobs.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + await expect( + analysis(page, "host-jobs").getByRole("button", { + name: "Retry refine job", + exact: true, + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + let linkedPanel = analysis(page, "host-review"); + let questionLink = linkedPanel.getByRole("button", { name: /^Question: show card for/ }); + await questionLink.focus(); + await expect(questionLink).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(linkedPanel).toHaveCount(0); + expect(await page.evaluate(() => window.analysisHostFixture.cards)).toEqual(["host-thread"]); + + expect(errors).toEqual([]); + }, +); diff --git a/e2e/source/analysis-host-native.ts b/e2e/source/analysis-host-native.ts new file mode 100644 index 00000000..48428a94 --- /dev/null +++ b/e2e/source/analysis-host-native.ts @@ -0,0 +1,98 @@ +import { expect } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { readdir, readFile } from "node:fs/promises"; +import { analysisHostBinding } from "./analysis-host.fixture"; +import type { Page } from "@playwright/test"; +import type { HostMode } from "./analysis-host.fixture"; + +let script: string, stylesheet: string; +export async function prepareAnalysisHost(): Promise { + if (typeof Bun === "undefined") throw new Error("Run through bun --bun"); + let entry = fileURLToPath(new URL("../../apps/web/src/chat/transcript.tsx", import.meta.url)); + let source = await Bun.file(entry).text(); + expect(source).toContain(" ({ + loader: "tsx", + contents: await Bun.file(args.path).text() + "\n" + analysisHostBinding, + })); + }, + }], + }); + expect(result.success, JSON.stringify(result.logs)).toBe(true); + let scripts = result.outputs.filter(output => output.path.endsWith(".js")); + expect(scripts).toHaveLength(1); + script = await scripts[0]!.text(); + // Root builds current web assets immediately before the native run. Use real compiled roles. + let assets = fileURLToPath(new URL("../../apps/web/dist/assets/", import.meta.url)); + let styles = (await readdir(assets)).filter(name => /^index-.*\.css$/.test(name)); + expect(styles).toHaveLength(1); + stylesheet = await readFile(assets + styles[0]!, "utf8"); + expect(stylesheet).toContain(".motion-popover"); +} +export let message = (page: Page, id: string) => page.locator(`[data-chat-message-id="${id}"]`); +export let analysis = (page: Page, id: string) => page.locator(`[data-analysis-message="${id}"]`); +export async function load(page: Page, mode: HostMode = "editable") { + let errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + let url = "https://analysis-host.invalid/"; + await page.route( + "**/*", + route => + route.request().url() === url && route.request().isNavigationRequest() + ? route.fulfill({ + contentType: "text/html", + body: '
', + }) + : route.abort(), + ); + await page.goto(url); + expect(await page.evaluate(() => document.compatMode)).toBe("CSS1Compat"); + expect( + await page.evaluate(() => window.isSecureContext && typeof crypto.randomUUID === "function"), + ).toBe(true); + await page.addStyleTag({ content: stylesheet }); + await page.addStyleTag({ + content: ` + body { margin:0; } + .analysis-host-chat { display:flex; flex-direction:column; width:min(460px,calc(100vw - 48px)); height:500px; margin:24px; } + .analysis-host-chat > [data-focus-boundary] { flex:1; min-height:0; overflow:auto; } + .analysis-host-chat .chat-composer { height:80px; flex:none; } + .analysis-host-chat [data-chat-stack] { display:flex; flex-direction:column; gap:16px; } + `, + }); + await page.addScriptTag({ content: script }); + await page.evaluate(mode => window.analysisHostFixture.mount(mode), mode); + await expect(message(page, "host-jobs")).toBeVisible(); + expect(errors).toEqual([]); + return errors; +} +export async function inspect(page: Page, id: string) { + let anchor = message(page, id); + await anchor.scrollIntoViewIfNeeded(); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).click(); + await expect(analysis(page, id)).toBeVisible(); +} +export async function openCorrection(page: Page) { + let anchor = message(page, "host-review"); + await anchor.scrollIntoViewIfNeeded(); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + let panel = analysis(page, "host-review"); + let correction = panel.getByRole("button", { name: "Add to card", exact: true }); + await correction.focus(); + await expect(correction).toBeFocused(); + await panel.getByRole("button", { name: "Add to card", exact: true }).click(); + await expect(panel.getByRole("combobox", { name: "Contribution type", exact: true })) + .toBeFocused(); + await panel.getByRole("combobox", { name: "Decision card", exact: true }).selectOption( + "host-thread", + ); + await panel.getByRole("textbox", { name: "Exact text", exact: true }).fill("encryption"); + return panel; +} diff --git a/e2e/source/analysis-host-retries.native.ts b/e2e/source/analysis-host-retries.native.ts new file mode 100644 index 00000000..e7c889fa --- /dev/null +++ b/e2e/source/analysis-host-retries.native.ts @@ -0,0 +1,132 @@ +import { expect, test } from "@playwright/test"; +import { + analysis, + inspect, + load, + openCorrection, + prepareAnalysisHost, +} from "./analysis-host-native"; +test.beforeAll(prepareAnalysisHost); + +test("actual Transcript correction holds the exact versioned payload and rejected retry keeps its identity", async ({ page }) => { + let errors = await load(page); + let panel = await openCorrection(page); + await panel.getByRole("combobox", { name: "Related option", exact: true }).selectOption( + "host-option", + ); + await page.evaluate(() => window.analysisHostFixture.hold("correction", true)); + await panel.getByRole("button", { name: "Add excerpt", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.corrections.length)).toBe( + 1, + ); + let first = await page.evaluate(() => window.analysisHostFixture.corrections[0]!); + expect(first).toEqual({ + actionId: expect.any(String), + threadId: "host-thread", + expectedVersion: 7, + change: { + kind: "add-excerpt", + messageId: "host-review", + start: 17, + end: 27, + contributionKind: "reason", + targetOptionId: "host-option", + }, + }); + await expect(panel.getByRole("button", { name: "Addingโ€ฆ", exact: true })).toBeDisabled(); + await expect(panel.getByRole("textbox", { name: "Exact text", exact: true })).toBeDisabled(); + await panel.getByRole("button", { name: "Addingโ€ฆ", exact: true }).evaluate(( + button: HTMLButtonElement, + ) => button.click()); + expect(await page.evaluate(() => window.analysisHostFixture.corrections.length)).toBe(1); + await page.evaluate(() => { + window.analysisHostFixture.hold("correction", false); + window.analysisHostFixture.settle("correction", false); + }); + await expect(panel.getByRole("alert")).toBeVisible(); + await panel.getByRole("button", { name: "Add excerpt", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.corrections.length)).toBe( + 2, + ); + expect(await page.evaluate(() => window.analysisHostFixture.corrections[1])).toEqual(first); + await expect(panel.getByRole("status")).toContainText( + "Added to Where should we store data? as a reason.", + ); + expect(errors).toEqual([]); +}); + +test("thread version refresh changes the next correction identity without silently using an old version", async ({ page }) => { + let errors = await load(page); + let panel = await openCorrection(page); + await page.evaluate(() => window.analysisHostFixture.rejectNext("correction")); + await panel.getByRole("button", { name: "Add excerpt", exact: true }).click(); + await expect(panel.getByRole("alert")).toBeVisible(); + let first = await page.evaluate(() => window.analysisHostFixture.corrections[0]!); + await page.evaluate(() => window.analysisHostFixture.version(8)); + await panel.getByRole("button", { name: "Add excerpt", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.corrections.length)).toBe( + 2, + ); + let second = await page.evaluate(() => window.analysisHostFixture.corrections[1]!); + expect(second.expectedVersion).toBe(8); + expect(second.actionId).not.toBe(first.actionId); + expect(second.change).toEqual(first.change); + expect(errors).toEqual([]); +}); + +test("failed analysis retains one action ID until acknowledged and allocates a fresh next attempt", async ({ page }) => { + let errors = await load(page); + await inspect(page, "host-retry"); + let panel = analysis(page, "host-retry"); + await page.evaluate(() => window.analysisHostFixture.hold("analysis", true)); + await panel.getByRole("button", { name: "Retry analysis", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.analyses.length)).toBe(1); + let first = await page.evaluate(() => window.analysisHostFixture.analyses[0]!); + expect(first).toEqual({ messageId: "host-retry", actionId: expect.any(String) }); + await expect(panel.getByRole("button", { name: "Retry analysis", exact: true })).toBeDisabled(); + await page.evaluate(() => { + window.analysisHostFixture.hold("analysis", false); + window.analysisHostFixture.settle("analysis", false); + }); + await expect(panel.getByRole("alert")).toContainText("Controlled held acknowledgement rejection"); + await panel.getByRole("button", { name: "Retry analysis", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.analyses.length)).toBe(2); + expect(await page.evaluate(() => window.analysisHostFixture.analyses[1])).toEqual(first); + await expect(panel.getByRole("button", { name: "Retry analysis", exact: true })).toBeEnabled(); + await panel.getByRole("button", { name: "Retry analysis", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.analyses.length)).toBe(3); + expect(await page.evaluate(() => window.analysisHostFixture.analyses[2]!.actionId)).not.toBe( + first.actionId, + ); + expect(errors).toEqual([]); +}); + +test("failed job retries serialize both controls and recover after a rejected acknowledgement", async ({ page }) => { + let errors = await load(page); + await inspect(page, "host-jobs"); + let panel = analysis(page, "host-jobs"); + await expect(panel).toContainText("No changes published."); + await page.evaluate(() => window.analysisHostFixture.hold("job", true)); + await panel.getByRole("button", { name: "Retry refine job", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.jobs.length)).toBe(1); + await expect(panel.getByRole("button", { name: "Retrying refine jobโ€ฆ", exact: true })) + .toBeDisabled(); + await expect(panel.getByRole("button", { name: "Retry heading job", exact: true })) + .toBeDisabled(); + await panel.getByRole("button", { name: "Retry heading job", exact: true }).evaluate(( + button: HTMLButtonElement, + ) => button.click()); + expect(await page.evaluate(() => window.analysisHostFixture.jobs)).toEqual(["host-job-0"]); + await page.evaluate(() => { + window.analysisHostFixture.hold("job", false); + window.analysisHostFixture.settle("job", false); + }); + await expect(panel.getByRole("alert")).toContainText("The Planner job could not be retried."); + await panel.getByRole("button", { name: "Retry heading job", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.analysisHostFixture.jobs.length)).toBe(2); + expect(await page.evaluate(() => window.analysisHostFixture.jobs)).toEqual([ + "host-job-0", + "host-job-1", + ]); + expect(errors).toEqual([]); +}); diff --git a/e2e/source/analysis-host.fixture.ts b/e2e/source/analysis-host.fixture.ts new file mode 100644 index 00000000..65a08425 --- /dev/null +++ b/e2e/source/analysis-host.fixture.ts @@ -0,0 +1,167 @@ +import type { ExcerptCorrectionAction } from "../../apps/web/src/conversation-plan/analysis-action"; + +type Command = "correction" | "analysis" | "job"; +export type HostMode = "editable" | "readonly"; +declare global { + interface Window { + analysisHostFixture: { + mount(mode?: HostMode): void; + unmount(): void; + hold(kind: Command, enabled: boolean): void; + rejectNext(kind: Command): void; + settle(kind: Command, accepted: boolean): void; + append(): void; + version(version: number): void; + editable(enabled: boolean): void; + diagnostics(enabled: boolean): void; + linked(enabled: boolean): void; + corrections: ExcerptCorrectionAction[]; + analyses: Array<{ messageId: string; actionId: string }>; + jobs: string[]; + cards: string[]; + }; + } +} + +export let analysisHostBinding = ` +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; + +let analysisHostText = "Prefix: S3 needs encryption. S3 is available."; +let analysisHostQuote = "S3 needs encryption. S3 is available."; +let analysisHostThread: ConversationPlan.Thread = { + id: "host-thread", question: "Where should we store data?", questionSources: [], + questionAuthoring: "quoted", status: "exploring", questionnaireId: "host-card", version: 7, + contributions: [{ id: "host-option", kind: "option", text: "S3", authoring: "quoted", sources: [], actor: { kind: "classifier" } }], + stances: [], stanceHistory: [], decisionHistory: [], candidates: [], +}; +let analysisHostLinkedThread: ConversationPlan.Thread = { + ...analysisHostThread, + questionSources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "Prefix", start: 0, end: 6, role: "question", + }], + contributions: [ + { + ...analysisHostThread.contributions[0]!, + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "S3", start: 8, end: 10, role: "option", + }], + }, + { + id: "host-reason", kind: "reason", text: "needs", authoring: "quoted", + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "needs", start: 11, end: 16, role: "reason", + }], + actor: { kind: "classifier" }, + }, + { + id: "host-constraint", kind: "constraint", text: "encryption", authoring: "quoted", + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "encryption", start: 17, end: 27, role: "constraint", + }], + actor: { kind: "classifier" }, + }, + ], +}; +let analysisHostRecord: ConversationPlan.AnalysisRecord = { + messageId: "host-review", questionSetVersion: "fixture-v1", modelVersion: "fixture-m1", + status: "unlinked", passes: [], eventIds: [], outcomes: [{ + start: analysisHostText.indexOf(analysisHostQuote), end: analysisHostText.length, + status: "review", gate: "target needs review", eventIds: [], + }], +}; +let analysisHostEntries: Chat.Entry[] = Array.from({ length: 16 }, (_, index) => ({ + id: \`host-background-\${index}\`, author: { kind: "member", handle: "ana" }, + text: \`Earlier context \${index}.\`, ts: index + 1, +})); +analysisHostEntries.push( + { id: "host-review", author: { kind: "member", handle: "ana" }, text: analysisHostText, ts: 17 }, + { id: "host-retry", author: { kind: "member", handle: "ben" }, text: "The analysis needs a retry.", ts: 18 }, + { id: "host-jobs", author: { kind: "member", handle: "cy" }, text: "A Planner job failed to update this card.", ts: 19 }, +); +let analysisHostJobs: ConversationPlan.Job[] = ["refine", "heading"].map((kind, index) => ({ + id: \`host-job-\${index}\`, kind: kind as "refine" | "heading", target: "host-card", + trigger: "host-jobs", status: "failed", attempts: 1, + reason: "Controlled failure before publication.", output: "No changes published.", + at: "2026-09-30T10:00:00Z", +})); +let analysisHostHolds = new Set(); +let analysisHostRejects = new Set(); +let analysisHostPending = new Map void; reject: (error: Error) => void }>(); +async function analysisHostAcknowledge(kind: string): Promise { + if (analysisHostRejects.delete(kind)) throw new Error("Controlled acknowledgement rejection"); + if (analysisHostHolds.has(kind)) await new Promise((resolve, reject) => { + if (analysisHostPending.has(kind)) throw new Error("Unexpected concurrent UI command"); + analysisHostPending.set(kind, { resolve, reject }); + }); +} +let analysisHostRoot = createRoot(document.querySelector("#fixture")!); +let analysisHostGeneration = 0; +function AnalysisHost({ mode }: { mode: "editable" | "readonly" }) { + let [entries, setEntries] = useState(analysisHostEntries); + let [canEdit, setEditable] = useState(mode === "editable"); + let [version, setVersion] = useState(7); + let [diagnostics, setDiagnostics] = useState(true); + window.analysisHostFixture.diagnostics = setDiagnostics; + let [linked, setLinked] = useState(false); + window.analysisHostFixture.editable = setEditable; + window.analysisHostFixture.version = setVersion; + window.analysisHostFixture.linked = setLinked; + window.analysisHostFixture.append = () => setEntries(current => [...current, { + id: \`appended-\${current.length}\`, author: { kind: "member", handle: "ana" }, + text: "A new message while the analysis remains pinned.", ts: current.length + 1, + }]); + let state: ConversationPlan.State = { + schemaVersion: 1, revision: 9, events: [], queue: [], + threads: [{ ...(linked ? analysisHostLinkedThread : analysisHostThread), version }], + analysis: diagnostics ? [analysisHostRecord, { + messageId: "host-retry", questionSetVersion: "fixture-v1", modelVersion: "fixture-m1", + status: "failed", passes: [], eventIds: [], error: "Controlled analysis failure.", + }] : [], + }; + let addExcerpt = async (action: ExcerptCorrectionAction) => { + window.analysisHostFixture.corrections.push(action); + await analysisHostAcknowledge("correction"); + }; + let retryAnalysis = async (messageId: string, actionId: string) => { + window.analysisHostFixture.analyses.push({ messageId, actionId }); + await analysisHostAcknowledge("analysis"); + }; + let retryJob = async (jobId: string) => { + window.analysisHostFixture.jobs.push(jobId); + await analysisHostAcknowledge("job"); + }; + return
+
+ {}} + canEdit={canEdit} conversationPlan={state} conversationPlanJobs={diagnostics ? analysisHostJobs : []} + onCardLink={link => window.analysisHostFixture.cards.push(link.threadId)} + onAddExcerpt={addExcerpt} onRetryAnalysis={retryAnalysis} onRetryJob={retryJob} /> +