From dbea4e43b63ffdb84427291aeff57768a144a87e Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:24:01 +0100 Subject: [PATCH] Activate Jev conversation decisions in the workspace Connect the durable conversation runtime to authenticated room lifecycle, question controls, evidence views, and document cards. Update browser compatibility and replace the scripted research child suite with record-backed recovery coverage. --- .../src/agent/revise-open-decision-4.test.ts | 7 +- .../accepted-research-close.test.ts | 76 +++ .../accepted-research-commit.test.ts | 63 ++ .../accepted-research-gates.test.ts | 79 +++ .../accepted-research-memory.test.ts | 80 +++ .../accepted-research-socket.test-fixtures.ts | 41 ++ .../accepted-research.test-fixtures.ts | 135 ++++ .../conversation-plan/accepted-research.ts | 79 +++ ...command-correction-memory.test-fixtures.ts | 66 ++ .../command-correction-memory.test.ts | 118 ++++ apps/server/src/conversation-plan/commands.ts | 130 ++++ .../conversation-plan/research-commands.ts | 108 ++++ .../conversation-plan/save-command.test.ts | 180 ++++++ .../src/conversation-plan/save-command.ts | 59 ++ .../service-opening-memory.test-fixtures.ts | 66 ++ .../service-opening-memory.test.ts | 337 ++++++++++ .../service-opening-order-memory.test.ts | 290 +++++++++ .../conversation-plan/service-opening.test.ts | 236 +++++++ .../src/conversation-plan/service-opening.ts | 111 ++++ apps/server/src/harness/tool-names.ts | 1 + apps/server/src/main.ts | 216 +++++-- .../questions/backfill-implementation.test.ts | 79 +++ apps/server/src/questions/backfill.test.ts | 256 ++++++++ apps/server/src/questions/backfill.ts | 142 +++++ apps/server/src/socket/authorization.test.ts | 60 ++ apps/server/src/socket/authorization.ts | 26 + apps/server/src/testing/card-gap-document.ts | 25 + apps/web/package.json | 1 + apps/web/src/chat/chat.tsx | 35 +- apps/web/src/chat/decision-entry.test.tsx | 267 ++++++++ apps/web/src/chat/decision-entry.tsx | 222 +++++++ apps/web/src/chat/markdown.css | 4 + apps/web/src/chat/model.test.ts | 15 + apps/web/src/chat/model.ts | 10 +- .../src/chat/research-offer-observer.test.ts | 127 ++++ .../chat/research-offer-transcript.test.ts | 73 +++ .../src/chat/research-offer.test-fixtures.ts | 68 ++ apps/web/src/chat/research-offer.test.ts | 77 +++ apps/web/src/chat/research-offer.tsx | 318 ++++++++++ .../web/src/chat/scoped-choice-entry.test.tsx | 156 +++++ apps/web/src/chat/scoped-choice-entry.tsx | 141 ++++ .../transcript-decisions.test-fixtures.ts | 214 +++++++ .../src/chat/transcript-decisions.test.tsx | 50 ++ .../chat/transcript-scoped-choice.test.tsx | 75 +++ apps/web/src/chat/transcript-source.test.tsx | 54 ++ apps/web/src/chat/transcript.test.tsx | 160 +++++ apps/web/src/chat/transcript.tsx | 253 +++++++- .../src/conversation-plan/analysis-action.ts | 8 + .../src/conversation-plan/analysis-labels.ts | 32 + .../analysis-overview.test.tsx | 58 ++ .../conversation-plan/analysis-overview.tsx | 240 +++++++ .../conversation-plan/announcements.test.ts | 168 +++++ .../src/conversation-plan/announcements.ts | 27 + apps/web/src/conversation-plan/card-parts.tsx | 29 + .../evidence-popover.test.tsx | 151 +++++ .../conversation-plan/evidence-popover.tsx | 115 ++++ .../src/conversation-plan/evidence.test.ts | 247 +++++++ apps/web/src/conversation-plan/evidence.ts | 109 ++++ .../conversation-plan/excerpt-correction.tsx | 226 +++++++ apps/web/src/conversation-plan/links.test.ts | 179 ++++++ apps/web/src/conversation-plan/links.ts | 97 +++ .../src/conversation-plan/markers.test.tsx | 181 ++++++ apps/web/src/conversation-plan/markers.tsx | 356 +++++++++++ .../planner-job-diagnostics.tsx | 80 +++ apps/web/src/conversation-plan/source.css | 5 + apps/web/src/conversation-plan/source.ts | 58 ++ apps/web/src/conversation-plan/store.test.ts | 119 ++++ apps/web/src/conversation-plan/store.ts | 74 +++ apps/web/src/document-workspace-host.tsx | 8 +- apps/web/src/room-workspace.tsx | 458 ++++++++++--- apps/web/src/theme.css | 22 +- apps/web/src/tokens.test.ts | 91 ++- apps/web/src/workspace-model.test.ts | 14 +- apps/web/src/workspace-model.ts | 2 +- apps/web/src/workspace.tsx | 49 +- bun.lock | 1 + e2e/decision-marker.e2e.ts | 26 +- e2e/discarded-mobile-geometry.e2e.ts | 36 ++ e2e/document-tab-stability.e2e.ts | 75 +++ e2e/editing.e2e.ts | 5 +- e2e/github.ts | 17 + e2e/playwright.config.ts | 28 + ....e2e.ts => research-child-recovery.e2e.ts} | 176 ++++- e2e/research-child-surface.e2e.ts | 9 +- e2e/research-recovery-database.ts | 176 +++++ e2e/responsive-decisions.e2e.ts | 12 +- e2e/responsive-workspace.e2e.ts | 208 ++++-- e2e/shell.e2e.ts | 12 +- e2e/sidecar-card-states.e2e.ts | 344 ++++++++++ e2e/sidecar-document-spacing.e2e.ts | 119 ++++ e2e/sidecar-draft-reopen.e2e.ts | 110 ++++ e2e/sidecar-evidence-absence.e2e.ts | 14 + e2e/sidecar-option-async.e2e.ts | 179 ++++++ e2e/sidecar-option-contracts.e2e.ts | 286 +++++++++ e2e/sidecar-spacing-database.ts | 60 ++ e2e/sidecar.e2e.ts | 79 ++- e2e/smoke.e2e.ts | 5 +- packages/editor/src/card-meta.test.ts | 110 ++++ packages/editor/src/card-meta.ts | 93 +++ packages/editor/src/comment-geometry.test.ts | 27 +- packages/editor/src/comment-geometry.ts | 28 + packages/editor/src/decision-pin.test.ts | 54 ++ packages/editor/src/decision-pin.ts | 37 ++ packages/editor/src/decision-placement.ts | 7 + packages/editor/src/decision-state.test.ts | 160 ++++- packages/editor/src/decision-state.ts | 35 +- .../editor/src/decisions-metadata.test.tsx | 73 +++ packages/editor/src/decisions.tsx | 50 +- packages/editor/src/evidence-geometry.test.ts | 44 ++ packages/editor/src/evidence-geometry.ts | 44 ++ packages/editor/src/index.ts | 3 + packages/editor/src/marks.test.ts | 113 +++- packages/editor/src/marks.ts | 105 ++- packages/editor/src/plan-editor.tsx | 24 +- packages/editor/src/presence-faces.tsx | 7 +- packages/editor/src/provider.test.ts | 83 ++- packages/editor/src/provider.ts | 28 +- packages/editor/src/questionnaires.test.ts | 201 +++++- packages/editor/src/questionnaires.ts | 104 ++- packages/editor/src/resolved-actions.ts | 114 ++++ packages/editor/src/resolved-layer.tsx | 216 ++++++- packages/editor/src/resolved-metadata.test.ts | 49 ++ packages/editor/src/resolved.ts | 29 + packages/editor/src/styles.css | 81 ++- packages/editor/src/threads.ts | 4 +- packages/editor/src/widget-options.ts | 6 + packages/editor/src/widgets-plugin.tsx | 6 + packages/editor/src/widgets/card-gap.test.ts | 38 ++ packages/editor/src/widgets/card-gap.tsx | 76 +++ .../editor/src/widgets/decision-deletion.tsx | 148 +++++ .../src/widgets/decorator-selection.test.ts | 40 +- .../src/widgets/decorator-selection.tsx | 5 +- .../widgets/discarded-navigation-boundary.ts | 27 + .../src/widgets/discarded-navigation.test.ts | 63 ++ .../src/widgets/discarded-navigation.tsx | 157 +++++ .../editor/src/widgets/evidence-hover.tsx | 216 +++++++ packages/editor/src/widgets/index.ts | 3 + .../questionnaire-collapse-list.test.tsx | 77 +++ .../questionnaire-collapse-presence.test.tsx | 58 ++ .../questionnaire-collapse-settled.test.tsx | 73 +++ .../widgets/questionnaire-lifecycle.test.tsx | 86 +++ .../questionnaire-metadata.test-fixtures.ts | 28 + .../widgets/questionnaire-metadata.test.tsx | 115 ++++ .../src/widgets/questionnaire-people.test.tsx | 36 ++ .../questionnaire-presentation.test.tsx | 40 ++ .../widgets/questionnaire-previous.test.tsx | 30 + .../src/widgets/questionnaire-source.test.tsx | 36 ++ .../questionnaire-terminal-semantics.test.tsx | 54 ++ packages/editor/src/widgets/questionnaire.tsx | 304 ++++++++- packages/icons/src/line.tsx | 19 +- .../question/src/react/add-option.test.ts | 63 ++ packages/question/src/react/index.ts | 8 + .../react/open-discard-compatibility.test.ts | 29 + .../question/src/react/open-discard.test.ts | 25 + .../src/react/previous-answer.test.ts | 43 ++ .../src/react/project-suggestion.test.ts | 128 ++++ .../question/src/react/project-suggestion.ts | 93 +++ .../react/question-view-projection.test.ts | 106 ++++ .../question-view-retained-contracts.test.ts | 117 ++++ .../question-view-terminal-origin.test.ts | 112 ++++ .../src/react/question-view.test-fixtures.ts | 14 + packages/question/src/react/question-view.tsx | 245 +++++-- .../src/react/questionnaire-controller.ts | 600 ++++++++++++++++++ .../question/src/react/questionnaire-types.ts | 43 ++ packages/question/src/react/refining.test.ts | 16 + .../src/react/resolved-actions.test.ts | 84 +++ .../question/src/react/resolved-actions.tsx | 75 +++ .../react/use-questionnaire-cancel.test.ts | 68 ++ .../use-questionnaire-definition.test.ts | 103 +++ .../react/use-questionnaire-lifecycle.test.ts | 83 +++ .../use-questionnaire-option-growth.test.ts | 139 ++++ .../use-questionnaire-refresh-save.test.ts | 133 ++++ .../src/react/use-questionnaire-save.test.ts | 136 ++++ .../react/use-questionnaire.test-fixtures.ts | 116 ++++ .../src/react/use-questionnaire.test.ts | 26 +- .../question/src/react/use-questionnaire.ts | 537 +--------------- .../exceptions/dynamic-editor.json | 88 +-- .../exceptions/dynamic-packages.json | 34 +- .../exceptions/dynamic-web.json | 20 +- 179 files changed, 16428 insertions(+), 1086 deletions(-) create mode 100644 apps/server/src/conversation-plan/accepted-research-close.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-commit.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-gates.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-memory.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-socket.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/accepted-research.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/accepted-research.ts create mode 100644 apps/server/src/conversation-plan/command-correction-memory.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/command-correction-memory.test.ts create mode 100644 apps/server/src/conversation-plan/commands.ts create mode 100644 apps/server/src/conversation-plan/research-commands.ts create mode 100644 apps/server/src/conversation-plan/save-command.test.ts create mode 100644 apps/server/src/conversation-plan/save-command.ts create mode 100644 apps/server/src/conversation-plan/service-opening-memory.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/service-opening-memory.test.ts create mode 100644 apps/server/src/conversation-plan/service-opening-order-memory.test.ts create mode 100644 apps/server/src/conversation-plan/service-opening.test.ts create mode 100644 apps/server/src/conversation-plan/service-opening.ts create mode 100644 apps/server/src/questions/backfill-implementation.test.ts create mode 100644 apps/server/src/questions/backfill.test.ts create mode 100644 apps/server/src/questions/backfill.ts create mode 100644 apps/server/src/socket/authorization.test.ts create mode 100644 apps/server/src/socket/authorization.ts create mode 100644 apps/server/src/testing/card-gap-document.ts create mode 100644 apps/web/src/chat/decision-entry.test.tsx create mode 100644 apps/web/src/chat/decision-entry.tsx create mode 100644 apps/web/src/chat/research-offer-observer.test.ts create mode 100644 apps/web/src/chat/research-offer-transcript.test.ts create mode 100644 apps/web/src/chat/research-offer.test-fixtures.ts create mode 100644 apps/web/src/chat/research-offer.test.ts create mode 100644 apps/web/src/chat/research-offer.tsx create mode 100644 apps/web/src/chat/scoped-choice-entry.test.tsx create mode 100644 apps/web/src/chat/scoped-choice-entry.tsx create mode 100644 apps/web/src/chat/transcript-decisions.test-fixtures.ts create mode 100644 apps/web/src/chat/transcript-decisions.test.tsx create mode 100644 apps/web/src/chat/transcript-scoped-choice.test.tsx create mode 100644 apps/web/src/chat/transcript-source.test.tsx create mode 100644 apps/web/src/conversation-plan/analysis-action.ts create mode 100644 apps/web/src/conversation-plan/analysis-labels.ts create mode 100644 apps/web/src/conversation-plan/analysis-overview.test.tsx create mode 100644 apps/web/src/conversation-plan/analysis-overview.tsx create mode 100644 apps/web/src/conversation-plan/announcements.test.ts create mode 100644 apps/web/src/conversation-plan/announcements.ts create mode 100644 apps/web/src/conversation-plan/card-parts.tsx create mode 100644 apps/web/src/conversation-plan/evidence-popover.test.tsx create mode 100644 apps/web/src/conversation-plan/evidence-popover.tsx create mode 100644 apps/web/src/conversation-plan/evidence.test.ts create mode 100644 apps/web/src/conversation-plan/evidence.ts create mode 100644 apps/web/src/conversation-plan/excerpt-correction.tsx create mode 100644 apps/web/src/conversation-plan/links.test.ts create mode 100644 apps/web/src/conversation-plan/links.ts create mode 100644 apps/web/src/conversation-plan/markers.test.tsx create mode 100644 apps/web/src/conversation-plan/markers.tsx create mode 100644 apps/web/src/conversation-plan/planner-job-diagnostics.tsx create mode 100644 apps/web/src/conversation-plan/source.css create mode 100644 apps/web/src/conversation-plan/source.ts create mode 100644 apps/web/src/conversation-plan/store.test.ts create mode 100644 apps/web/src/conversation-plan/store.ts create mode 100644 e2e/discarded-mobile-geometry.e2e.ts create mode 100644 e2e/document-tab-stability.e2e.ts rename e2e/{research-child-documents.e2e.ts => research-child-recovery.e2e.ts} (73%) create mode 100644 e2e/sidecar-card-states.e2e.ts create mode 100644 e2e/sidecar-document-spacing.e2e.ts create mode 100644 e2e/sidecar-draft-reopen.e2e.ts create mode 100644 e2e/sidecar-evidence-absence.e2e.ts create mode 100644 e2e/sidecar-option-async.e2e.ts create mode 100644 e2e/sidecar-option-contracts.e2e.ts create mode 100644 e2e/sidecar-spacing-database.ts create mode 100644 packages/editor/src/card-meta.test.ts create mode 100644 packages/editor/src/card-meta.ts create mode 100644 packages/editor/src/decision-pin.test.ts create mode 100644 packages/editor/src/decision-pin.ts create mode 100644 packages/editor/src/decision-placement.ts create mode 100644 packages/editor/src/decisions-metadata.test.tsx create mode 100644 packages/editor/src/evidence-geometry.test.ts create mode 100644 packages/editor/src/evidence-geometry.ts create mode 100644 packages/editor/src/resolved-actions.ts create mode 100644 packages/editor/src/resolved-metadata.test.ts create mode 100644 packages/editor/src/widgets/card-gap.test.ts create mode 100644 packages/editor/src/widgets/card-gap.tsx create mode 100644 packages/editor/src/widgets/decision-deletion.tsx create mode 100644 packages/editor/src/widgets/discarded-navigation-boundary.ts create mode 100644 packages/editor/src/widgets/discarded-navigation.test.ts create mode 100644 packages/editor/src/widgets/discarded-navigation.tsx create mode 100644 packages/editor/src/widgets/evidence-hover.tsx create mode 100644 packages/editor/src/widgets/questionnaire-collapse-list.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-collapse-presence.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-collapse-settled.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-lifecycle.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-metadata.test-fixtures.ts create mode 100644 packages/editor/src/widgets/questionnaire-metadata.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-people.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-presentation.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-previous.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-source.test.tsx create mode 100644 packages/editor/src/widgets/questionnaire-terminal-semantics.test.tsx create mode 100644 packages/question/src/react/add-option.test.ts create mode 100644 packages/question/src/react/open-discard-compatibility.test.ts create mode 100644 packages/question/src/react/open-discard.test.ts create mode 100644 packages/question/src/react/previous-answer.test.ts create mode 100644 packages/question/src/react/project-suggestion.test.ts create mode 100644 packages/question/src/react/project-suggestion.ts create mode 100644 packages/question/src/react/question-view-projection.test.ts create mode 100644 packages/question/src/react/question-view-retained-contracts.test.ts create mode 100644 packages/question/src/react/question-view-terminal-origin.test.ts create mode 100644 packages/question/src/react/question-view.test-fixtures.ts create mode 100644 packages/question/src/react/questionnaire-controller.ts create mode 100644 packages/question/src/react/questionnaire-types.ts create mode 100644 packages/question/src/react/refining.test.ts create mode 100644 packages/question/src/react/resolved-actions.test.ts create mode 100644 packages/question/src/react/resolved-actions.tsx create mode 100644 packages/question/src/react/use-questionnaire-cancel.test.ts create mode 100644 packages/question/src/react/use-questionnaire-definition.test.ts create mode 100644 packages/question/src/react/use-questionnaire-lifecycle.test.ts create mode 100644 packages/question/src/react/use-questionnaire-option-growth.test.ts create mode 100644 packages/question/src/react/use-questionnaire-refresh-save.test.ts create mode 100644 packages/question/src/react/use-questionnaire-save.test.ts create mode 100644 packages/question/src/react/use-questionnaire.test-fixtures.ts diff --git a/apps/server/src/agent/revise-open-decision-4.test.ts b/apps/server/src/agent/revise-open-decision-4.test.ts index 1fe75bca..3f6dcae9 100644 --- a/apps/server/src/agent/revise-open-decision-4.test.ts +++ b/apps/server/src/agent/revise-open-decision-4.test.ts @@ -10,16 +10,15 @@ let fixture = createReviseOpenDecisionFixture(() => contexts, value => { contexts = fixture.contexts; let { opened } = fixture; -test("the direct card tool is built but withheld from ordinary hosted turns until card sync", async () => { +test("the direct card tool is present in the hosted model's custom tools", async () => { let context = await opened([{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Anchors" }]); let config = plannerConfiguration({ model: "model" }, { tools: context.tools }, { token: "ghu_owner", repository: { id: "R_repo", owner: "octo-org", name: "score", defaultBranch: "main" }, }); expect(config.availableTools).toContain("custom:*"); - expect(config.tools?.map(tool => tool.name)).not.toContain("revise_open_decision"); - expect(context.tools.map(tool => tool.name)).toContain("revise_open_decision"); - expect(context.tools.find(tool => tool.name === "revise_open_decision")?.skipPermission) + expect(config.tools?.map(tool => tool.name)).toContain("revise_open_decision"); + expect(config.tools?.find(tool => tool.name === "revise_open_decision")?.skipPermission) .toBe(false); let read = context.tools.find(tool => tool.name === "read_plan")!; let snapshot = JSON.parse(String(await read.handler!({}, {} as never))); diff --git a/apps/server/src/conversation-plan/accepted-research-close.test.ts b/apps/server/src/conversation-plan/accepted-research-close.test.ts new file mode 100644 index 00000000..4452c423 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research-close.test.ts @@ -0,0 +1,76 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { acceptedResearchMemory } from "./accepted-research.test-fixtures"; + +test("stop and idle drain admitted research consent through its durable receipt before close", async () => { + let h = await acceptedResearchMemory(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let originalStart = h.deps.start; + h.deps.start = async (...args) => { + let result = await originalStart(...args); + entered.resolve(); + await release.promise; + return result; + }; + let closed = false; + let commitsAfterClose = 0; + let originalCommit = h.opened.storage.collaboration.commit; + let commit = spyOn(h.opened.storage.collaboration, "commit").mockImplementation(async input => { + if (closed) commitsAfterClose++; + return originalCommit(input); + }); + let consent = h.consent(); + let closing: Promise | undefined; + try { + await entered.promise; + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toHaveLength(1); + expect(Plan.source(h.plan)).toContain(" { + idleResolved = true; + let saved = await h.opened.storage.collaboration.load(h.plan.id, new Date()); + let sidecar = saved?.sidecar; + if (sidecar && typeof sidecar === "object" && !Array.isArray(sidecar)) { + let receipts = sidecar.conversationPlanEffects; + let pending = sidecar.conversationPlanPendingEffects; + receiptBeforeClose = Array.isArray(receipts) && receipts.includes(`research:${h.offerId}`) + && (pending === undefined || Array.isArray(pending) && pending.length === 0); + } + closeCalled = true; + await Plan.close(h.plan); + closed = true; + }); + await Bun.sleep(10); + expect(idleResolved).toBe(false); + expect(closeCalled).toBe(false); + expect(h.plan.persistence.closing).toBe(false); + release.resolve(); + expect(await consent).toMatchObject({ execution: "started" }); + await closing; + expect(idleResolved).toBe(true); + expect(receiptBeforeClose).toBe(true); + expect(commitsAfterClose).toBe(0); + commit.mockRestore(); + let reopened = await Plan.open(h.plan.id, h.opened.backend, h.opened.server); + try { + expect(reopened.conversationPlanPendingEffects).toEqual([]); + expect(reopened.conversationPlanEffects).toContain(`research:${h.offerId}`); + expect(Plan.source(reopened)).toContain(" { + let h = await acceptedResearchMemory(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let original = h.opened.storage.collaboration.commit; + let commit = spyOn(h.opened.storage.collaboration, "commit").mockImplementation(async input => { + if (input.update) { + entered.resolve(); + await release.promise; + } + return original(input); + }); + let consent = h.consent(); + try { + await entered.promise; + let saved = await h.opened.storage.collaboration.load(h.plan.id, new Date()); + expect(JSON.stringify(saved!.sidecar)).toContain('"status":"accepted"'); + expect((await Plan.readStored(saved!)).source).not.toContain(" { + let h = await acceptedResearchMemory(); + let original = h.opened.storage.collaboration.commit; + let commit = spyOn(h.opened.storage.collaboration, "commit").mockImplementation(async input => { + if (input.update) throw new Error("reference commit rejected"); + return original(input); + }); + try { + expect(await h.consent()).toMatchObject({ status: "accepted", execution: "pending-retry" }); + expect(Plan.source(h.plan)).not.toContain(" frame.kind === "plan:update")).toEqual([]); + expect(h.errors).toHaveLength(1); + } finally { + commit.mockRestore(); + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/accepted-research-gates.test.ts b/apps/server/src/conversation-plan/accepted-research-gates.test.ts new file mode 100644 index 00000000..46953bb3 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research-gates.test.ts @@ -0,0 +1,79 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { acceptedResearchMemory } from "./accepted-research.test-fixtures"; + +for (let gate of ["viewer", "archived", "closing", "unavailable"] as const) { + test(`refuses research consent through the ${gate} write gate`, async () => { + let h = await acceptedResearchMemory(); + try { + if (gate === "viewer") h.ws.data.canEdit = false; + if (gate === "archived") { + await h.opened.storage.channels.archive({ id: h.plan.id, now: new Date() }); + h.ws.data.channelArchivedAt = new Date().toISOString(); + } + if (gate === "closing") h.room.closing = Promise.resolve(); + if (gate === "unavailable") h.setUnavailable(true); + expect(await h.consent()).toMatchObject({ kind: "session:error" }); + expect(h.plan.conversationPlan.researchOffers![0]!.status).toBe("offered"); + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toEqual([]); + expect(await h.opened.storage.research.list(h.plan.id, 100)).toEqual([]); + expect(Plan.source(h.plan)).not.toContain(" { + let h = await acceptedResearchMemory(); + try { + let pending = await h.processor.researchConsent( + { offerId: h.offerId, choice: "research", actionId: "human-consent" }, + { kind: "member", handle: "test" }, + "U_test", + ); + expect(pending.execution).toBe("pending-retry"); + expect(await h.link()).toMatchObject({ status: "pending" }); + expect(await h.link()).toMatchObject({ status: "pending" }); + h.setAccess("unavailable"); + expect(await h.link()).toMatchObject({ + kind: "session:error", + message: "authorization is temporarily unavailable", + }); + expect(h.closes).toEqual([]); + h.setAccess("denied"); + expect(await h.link()).toMatchObject({ + kind: "session:error", + message: "authorization expired", + }); + expect(h.closes).toEqual([4403]); + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toEqual([]); + expect(await h.opened.storage.research.list(h.plan.id, 100)).toEqual([]); + expect(Plan.source(h.plan)).not.toContain(" { + let h = await acceptedResearchMemory(); + let repository = h.context.repository; + let access = spyOn(h.context.auth.github, "repositoryAccess").mockResolvedValue({ + ...repository, + fullName: "owner/repository", + private: false, + url: "https://github.com/owner/repository", + permissions: { pull: true, push: false, admin: false }, + }); + try { + expect(await h.consent()).toMatchObject({ execution: "pending-owner", status: "accepted" }); + expect(await h.link()).toMatchObject({ status: "pending" }); + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toEqual([]); + expect(await h.opened.storage.research.list(h.plan.id, 100)).toEqual([]); + expect(Plan.source(h.plan)).not.toContain(" { + let h = await acceptedResearchMemory(); + let enqueue = h.opened.storage.jobs.enqueue; + let observations: string[] = []; + let watch = spyOn(h.opened.storage.jobs, "enqueue").mockImplementation(async input => { + let saved = await h.opened.storage.collaboration.load(h.plan.id, new Date()); + let restored = await Plan.readStored(saved!); + expect(JSON.stringify(saved!.sidecar)).toContain('"principalId":"U_test"'); + expect(JSON.stringify(saved!.sidecar)).toContain('"status":"accepted"'); + let offer = h.plan.conversationPlan.researchOffers![0]!; + let link = await h.research.acceptedOfferLink(h.plan.id, offer); + let detail = await h.opened.storage.research.get(h.plan.id, link.researchRequestId!); + expect(restored.source).toContain(``); + expect(detail!.workspace).toMatchObject({ inlineReference: "placed", createdBy: "U_test" }); + expect(detail!.turns[0]).toMatchObject({ requestedBy: "U_test", evidenceJobId: undefined }); + expect(detail!.messages[0]).toMatchObject({ userId: "U_test", userHandle: "test" }); + expect(offer.action).toMatchObject({ + principalId: "U_test", + actor: { kind: "member", handle: "test" }, + }); + observations.push(input.type); + return enqueue(input); + }); + try { + let response = await h.consent(); + expect(response).toMatchObject({ kind: "conversation-plan:research", execution: "started" }); + expect(observations).toEqual(["research-evidence"]); + expect(await h.link()).toMatchObject({ + status: "linked", + researchRequestId: response.researchRequestId, + }); + let jobs = await h.researchJobs.list(h.plan.id, 100); + let before = h.opened.broadcasts.length; + expect(await h.consent()).toMatchObject({ execution: "started" }); + expect(await h.link()).toMatchObject({ status: "linked" }); + expect(await h.researchJobs.list(h.plan.id, 100)).toEqual(jobs); + expect(h.opened.broadcasts.length).toBe(before); + expect(h.plan.conversationPlanEffects).toContain(`research:${h.offerId}`); + let raw = await h.opened.storage.collaboration.load(h.plan.id, new Date()); + expect(JSON.stringify(raw!.sidecar)).toContain('"principalId":"U_test"'); + expect(h.errors).toEqual([]); + } finally { + watch.mockRestore(); + await h.close(); + } +}); + +test("revoked authenticated owner leaves consent and placed request pending without enqueue", async () => { + let h = await acceptedResearchMemory(); + h.afterPlace(async () => { + await h.revokeSession(); + }); + try { + expect(await h.consent()).toMatchObject({ execution: "pending-owner", status: "accepted" }); + expect(await h.link()).toMatchObject({ status: "unlinked" }); + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toEqual([]); + expect(h.plan.conversationPlanPendingEffects).toContainEqual({ + key: `research:${h.offerId}`, + kind: "research", + offerId: h.offerId, + }); + expect(h.plan.conversationPlanEffects).not.toContain(`research:${h.offerId}`); + let offer = h.plan.conversationPlan.researchOffers![0]!; + let action = structuredClone(offer.action); + await h.member("U_other", "another-member"); + expect(await h.resume()).toMatchObject({ execution: "pending-owner" }); + expect(h.plan.conversationPlan.researchOffers![0]!.action).toEqual(action); + expect(await h.link()).toMatchObject({ status: "unlinked" }); + expect((await h.researchJobs.list(h.plan.id, 100))?.jobs).toEqual([]); + let raw = await h.opened.storage.collaboration.load(h.plan.id, new Date()); + expect(JSON.stringify(raw!.sidecar)).toContain('"principalId":"U_test"'); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/accepted-research-socket.test-fixtures.ts b/apps/server/src/conversation-plan/accepted-research-socket.test-fixtures.ts new file mode 100644 index 00000000..e0629964 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research-socket.test-fixtures.ts @@ -0,0 +1,41 @@ +import type { Socket, SocketData } from "../wire"; +import type { headingMemory } from "../chat/job-heading-memory.test-fixtures"; + +export function researchSocket( + h: Awaited>, + session: { id: string; cookie: string; expiresAt: Date }, +) { + let frames: Array> = []; + let closes: number[] = []; + let data: SocketData = { + handle: "test", + client: "consent-client", + room: h.room.id, + channelTitle: h.opened.channel.title, + channelSlug: h.opened.channel.slug, + channelUpdatedAt: h.opened.channel.updatedAt.toISOString(), + channelDescriptionRevision: 0, + canEdit: true, + canManage: true, + principalId: "U_test", + sessionId: session.id, + authorizedUntil: session.expiresAt.getTime(), + credential: session.cookie.split(";")[0]!, + repositoryId: h.opened.channel.repositoryId, + repositoryOwner: "owner", + repositoryName: "repository", + repositoryDefaultBranch: "main", + accessCheckedAt: Date.now(), + }; + let ws = { + data, + send(value: string) { + frames.push(JSON.parse(value)); + }, + close(code: number) { + closes.push(code); + }, + } as unknown as Socket; + h.room.members.set(data.client, ws); + return { ws, frames, closes, data }; +} diff --git a/apps/server/src/conversation-plan/accepted-research.test-fixtures.ts b/apps/server/src/conversation-plan/accepted-research.test-fixtures.ts new file mode 100644 index 00000000..504fc0f2 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research.test-fixtures.ts @@ -0,0 +1,135 @@ +import { researchSocket } from "./accepted-research-socket.test-fixtures"; +import { researchStorage } from "./accepted-research-storage.test-fixtures"; +import * as Plan from "../plan/service"; +import { headingMemory } from "../chat/job-heading-memory.test-fixtures"; +import { offerResearch } from "./domain"; +import { message, proposal } from "./research-offers.test-fixtures"; +import { startAcceptedResearch } from "./accepted-research"; +import { handleResearchCommand } from "./research-commands"; +import type { AuthorizationResult, Socket } from "../wire"; +import type { ConversationPlan, Request as Frame } from "@chopin/protocol"; + +async function noop() {} + +export async function acceptedResearchMemory() { + let h = await headingMemory(); + let auth = h.context.auth; + let session = await auth.sessions.issue("U_test", { + accessToken: "consent-token", + accessExpiresIn: 28_800, + refreshToken: "consent-refresh", + refreshExpiresIn: 15_897_600, + }); + h.context.claimantSessionId = session.id; + let { ws, frames, closes, data } = researchSocket(h, session); + let { research, jobs } = researchStorage(h); + let entry = message("research-consent-source", "Which public evidence supports this API?"); + await Plan.exclusive(h.plan, async () => { + h.plan.chat.entries.push(entry); + h.plan.conversationPlan = offerResearch(h.plan.conversationPlan, proposal(entry), entry); + await Plan.persistExclusive(h.plan); + }); + let offerId = h.plan.conversationPlan.researchOffers![0]!.id; + let access: AuthorizationResult = "allowed"; + let unavailable = false; + let afterPlace: () => Promise = noop; + let refreshAccess = async () => access; + let startDeps = { + research: () => research, + auth, + refreshAccess, + unavailable: () => unavailable, + ownerAvailable: async () => { + let binding = await h.context.activeOwner!(); + if (!binding) throw new Error("active owner unavailable"); + binding.release(); + h.runtime.wake(h.plan); + }, + placeReference: async (_channelId: string, workspaceId: string) => { + let placed = await Plan.placeResearchReference(h.plan, workspaceId); + await afterPlace(); + return placed; + }, + scheduleRecovery: (_deferred: number) => {}, + }; + let deps = { + enabled: true, + runtime: h.runtime, + research: () => research, + unavailable: () => unavailable, + refreshAccess, + start: ( + room: typeof h.room, + socket: Socket, + opened: typeof h.plan, + offer: ConversationPlan.ResearchOffer, + ) => startAcceptedResearch(room, socket, opened, offer, startDeps), + }; + async function command( + frame: Frame, + ) { + await handleResearchCommand(frame, h.room, ws, deps); + return frames.at(-1)!; + } + return { + ...h, + ws, + frames, + closes, + research, + researchJobs: jobs, + offerId, + deps, + consent: () => + command({ + kind: "conversation-plan:research", + ts: 0, + rid: "consent", + offerId, + choice: "research", + actionId: "human-consent", + }), + resume: () => + command({ + kind: "conversation-plan:research", + ts: 0, + rid: "resume", + offerId, + choice: "resume", + }), + link: () => command({ kind: "conversation-plan:research-link", ts: 0, rid: "lookup", offerId }), + afterPlace(callback: typeof afterPlace) { + afterPlace = callback; + }, + setAccess(value: AuthorizationResult) { + access = value; + }, + setUnavailable(value: boolean) { + unavailable = value; + }, + async member(userId: string, handle: string) { + await h.opened.storage.users.put({ + id: userId, + login: handle, + avatarUrl: "", + now: new Date(), + }); + let issued = await auth.sessions.issue(userId, { + accessToken: "member-token", + accessExpiresIn: 28_800, + refreshToken: "member-refresh", + refreshExpiresIn: 15_897_600, + }); + data.handle = handle; + data.principalId = userId; + data.sessionId = issued.id; + data.credential = issued.cookie.split(";")[0]!; + }, + revokeSession: () => + auth.sessions.revoke( + new Request("https://example.test/", { + headers: { cookie: data.credential }, + }), + ), + }; +} diff --git a/apps/server/src/conversation-plan/accepted-research.ts b/apps/server/src/conversation-plan/accepted-research.ts new file mode 100644 index 00000000..d924e9d5 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research.ts @@ -0,0 +1,79 @@ +import * as Chat from "../chat/service"; +import { ResearchWorkspaceError } from "../research/service"; +import type { ResearchWorkspaceService } from "../research/service"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { HostedAuth } from "../auth/routes"; +import type { AuthorizationResult, Socket } from "../wire"; +import type * as Rooms from "../rooms"; +import type * as Service from "../plan/service"; + +type AcceptedResearchDeps = { + research: () => ResearchWorkspaceService | undefined; + auth: HostedAuth; + refreshAccess: (ws: Socket, force: boolean) => Promise; + unavailable: (id: string) => boolean; + ownerAvailable: (id: string) => Promise; + placeReference: (channelId: string, workspaceId: string) => Promise<"placed" | "deferred">; + scheduleRecovery: (deferred: number) => void; +}; + +/** Retained consent execution; repository and process owner are rechecked at enqueue. */ +export async function startAcceptedResearch( + room: Rooms.Room, + ws: Socket, + opened: Service.Plan, + offer: ConversationPlan.ResearchOffer, + deps: AcceptedResearchDeps, +): Promise< + | { execution: "started"; researchRequestId: string } + | { execution: "pending-owner" } +> { + let service = deps.research(); + if (!service || !offer.action) throw new Error("research workspaces are unavailable"); + let repository = { + id: ws.data.repositoryId, + owner: ws.data.repositoryOwner, + name: ws.data.repositoryName, + defaultBranch: ws.data.repositoryDefaultBranch, + }; + let ownerReady = async () => { + let access = await deps.refreshAccess(ws, true); + if ( + access !== "allowed" || !ws.data.canEdit || ws.data.channelArchivedAt + || room.plan !== opened || room.closing || deps.unavailable(room.id) + || deps.unavailable(room.id) + ) return false; + try { + await Chat.resolveOwner(deps.auth, repository, room.id, ws.data.sessionId); + return true; + } catch { + return false; + } + }; + if (!await ownerReady()) return { execution: "pending-owner" }; + let ownerUnavailable = false; + try { + let created = await service.startPlannerInline({ + channelId: room.id, + question: offer.brief, + originMessageId: offer.source.messageId, + requestedBy: offer.action.principalId, + requestedByHandle: offer.action.actor.handle, + beforeStart: async () => { + if (!await ownerReady()) { + ownerUnavailable = true; + throw new Error("research owner is unavailable"); + } + await deps.ownerAvailable(room.id); + }, + placeReference: id => deps.placeReference(room.id, id), + }); + return { execution: "started", researchRequestId: created.request.id }; + } catch (error) { + if (ownerUnavailable) return { execution: "pending-owner" }; + if (error instanceof ResearchWorkspaceError && error.code === "not-ready") { + deps.scheduleRecovery(1); + } + throw error; + } +} diff --git a/apps/server/src/conversation-plan/command-correction-memory.test-fixtures.ts b/apps/server/src/conversation-plan/command-correction-memory.test-fixtures.ts new file mode 100644 index 00000000..4fc604d7 --- /dev/null +++ b/apps/server/src/conversation-plan/command-correction-memory.test-fixtures.ts @@ -0,0 +1,66 @@ +import * as Plan from "../plan/service"; +import * as Store from "../questions/store"; +import { handleConversationCommand } from "./commands"; +import { excerptAction, excerptCorrectionSetup } from "./service-correction.test-fixtures"; +import { memoryProcessor } from "./service-memory.test-fixtures"; + +import type { ConversationPlan, Request } from "@chopin/protocol"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +export async function correctionCommand() { + let setup = await memoryProcessor(); + let seed = excerptCorrectionSetup(); + setup.plan.chat.entries = structuredClone(seed.setup.plan.chat.entries); + setup.plan.conversationPlan = structuredClone(seed.setup.plan.conversationPlan); + setup.plan.records = structuredClone(seed.setup.plan.records); + for (let record of setup.plan.records.values()) { + Store.reopen(setup.plan.questions, record.id, record.definition, record.id); + } + await Plan.persist(setup.plan); + let processor = setup.start(); + let room: Room = { id: setup.plan.id, plan: setup.plan, members: new Map() }; + let frames: Array> = []; + let snapshots: Array> = []; + let ws = { + data: { canEdit: true, handle: "bob", channelArchivedAt: undefined }, + send(value: string) { + frames.push(JSON.parse(value)); + snapshots.push(setup.saved()); + }, + } as unknown as Socket; + let deps: Parameters[3] = { + enabled: true, + unavailable: () => false, + refreshAccess: async () => "allowed", + chat: () => { + throw new Error("Correction commands do not claim Planner context"); + }, + // Runtime lookup is a fixture boundary; commands, processor and storage are real. + runtime: { processor: opened => opened === setup.plan ? processor : undefined } as Parameters< + typeof handleConversationCommand + >[3]["runtime"], + }; + let action = excerptAction(setup.plan.conversationPlan, seed.excerpt, seed.start, seed.end); + let frame: Request = { + kind: "conversation-plan:correct", + rid: "correction-1", + ts: 0, + ...action, + }; + return { + setup, + room, + ws, + deps, + frames, + snapshots, + frame, + seed, + async reopen() { + await setup.reopen(); + room.plan = setup.plan; + processor = setup.start(); + }, + }; +} diff --git a/apps/server/src/conversation-plan/command-correction-memory.test.ts b/apps/server/src/conversation-plan/command-correction-memory.test.ts new file mode 100644 index 00000000..39866450 --- /dev/null +++ b/apps/server/src/conversation-plan/command-correction-memory.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { handleConversationCommand } from "./commands"; +import { correctionCommand } from "./command-correction-memory.test-fixtures"; + +// These tests cover command-local gates, not socket admission or GitHub authorization. +test("correction commands commit attributed source evidence before reply and survive reopen", async () => { + let h = await correctionCommand(); + try { + await handleConversationCommand(h.frame, h.room, h.ws, h.deps); + let replied = await h.snapshots[0]!; + let event = replied.conversationPlan.events.at(-1)!; + let quote = h.seed.excerpt.text.slice(h.seed.start, h.seed.end); + expect(event).toMatchObject({ + id: "human:bob:add-excerpt", + type: "reason.added", + origin: "human", + actor: { kind: "member", handle: "bob" }, + source: { + messageId: h.seed.excerpt.id, + author: { kind: "member", handle: "alice" }, + quote, + start: h.seed.start, + end: h.seed.end, + role: "reason", + }, + }); + expect(h.frames).toEqual([{ + kind: "conversation-plan:correct", + rid: h.frame.rid, + ts: expect.any(Number), + eventId: event.id, + revision: replied.conversationPlan.revision, + }]); + expect(h.setup.publications).toEqual([replied.conversationPlan]); + await h.reopen(); + expect(h.setup.plan.conversationPlan).toEqual(replied.conversationPlan); + expect(replied.transcript).toEqual(h.seed.setup.plan.chat.entries); + expect(h.setup.plan.chat.entries).toEqual(h.seed.setup.plan.chat.entries); + await handleConversationCommand({ ...h.frame, rid: "correction-again" }, h.room, h.ws, h.deps); + expect(h.frames[1]).toMatchObject({ + kind: "conversation-plan:correct", + rid: "correction-again", + eventId: event.id, + revision: replied.conversationPlan.revision, + }); + expect(h.setup.plan.conversationPlan.events.filter(item => item.id === event.id)).toHaveLength( + 1, + ); + expect(h.setup.publications).toHaveLength(1); + expect(h.setup.errors).toEqual([]); + } finally { + await h.setup.close(); + } +}); + +test("correction command storage failure refuses without publishing and remains retryable after reopen", async () => { + let h = await correctionCommand(); + try { + let before = await h.setup.saved(); + h.setup.failNextCommit(); + await handleConversationCommand(h.frame, h.room, h.ws, h.deps); + expect(h.frames).toEqual([{ + kind: "session:error", + rid: h.frame.rid, + ts: expect.any(Number), + message: "memory commit rejected", + }]); + expect(await h.snapshots[0]!).toEqual(before); + expect(await h.setup.saved()).toEqual(before); + expect(h.setup.plan.conversationPlan).toEqual(before.conversationPlan); + expect(h.setup.publications).toEqual([]); + expect(h.setup.fatals).toHaveLength(1); + await h.reopen(); + expect(h.setup.plan.conversationPlan).toEqual(before.conversationPlan); + await handleConversationCommand({ ...h.frame, rid: "correction-retry" }, h.room, h.ws, h.deps); + let saved = await h.setup.saved(); + expect(h.frames[1]).toMatchObject({ + kind: "conversation-plan:correct", + rid: "correction-retry", + eventId: "human:bob:add-excerpt", + revision: saved.conversationPlan.revision, + }); + expect(saved.conversationPlan.events.filter(item => item.id === "human:bob:add-excerpt")) + .toHaveLength(1); + expect(h.setup.publications).toEqual([saved.conversationPlan]); + } finally { + await h.setup.close(); + } +}); + +test.each(["reader", "archived", "disabled", "unavailable"] as const)( + "correction command refuses %s before any durable mutation", + async condition => { + let h = await correctionCommand(); + try { + let before = await h.setup.saved(); + if (condition === "reader") h.ws.data.canEdit = false; + if (condition === "archived") h.ws.data.channelArchivedAt = "2026-09-30T00:00:00Z"; + if (condition === "disabled") h.deps.enabled = false; + if (condition === "unavailable") h.deps.unavailable = () => true; + await handleConversationCommand(h.frame, h.room, h.ws, h.deps); + expect(h.frames).toEqual([{ + kind: "session:error", + rid: h.frame.rid, + ts: expect.any(Number), + message: condition === "disabled" + ? "conversation analysis is disabled" + : "repository write access is required", + }]); + expect(await h.setup.saved()).toEqual(before); + expect(h.setup.plan.conversationPlan).toEqual(before.conversationPlan); + expect(h.setup.publications).toEqual([]); + expect(h.setup.fatals).toEqual([]); + } finally { + await h.setup.close(); + } + }, +); diff --git a/apps/server/src/conversation-plan/commands.ts b/apps/server/src/conversation-plan/commands.ts new file mode 100644 index 00000000..cbec04ce --- /dev/null +++ b/apps/server/src/conversation-plan/commands.ts @@ -0,0 +1,130 @@ +import type { ConversationPlan, Request } from "@chopin/protocol"; +import type { AuthorizationResult, Socket } from "../wire"; +import type { Room } from "../rooms"; +import type * as Chat from "../chat/service"; +import { fail, reply } from "../wire"; +import { handleScopedChoiceSave } from "./save-command"; +import type { createConversationRuntime } from "./runtime"; + +type Command = Request< + | ConversationPlan.Correct + | ConversationPlan.SaveScopedChoice + | ConversationPlan.Retry + | ConversationPlan.RetryJob +>; +type CommandDeps = { + enabled: boolean; + runtime: ReturnType; + unavailable: (id: string) => boolean; + refreshAccess: () => Promise; + chat: () => Chat.Room; +}; + +/** The socket admission layer rechecks write access before dispatching these commands. */ +export async function handleConversationCommand( + frame: Command, + room: Room, + ws: Socket, + deps: CommandDeps, +): Promise { + switch (frame.kind) { + case "conversation-plan:correct": { + try { + if (!deps.enabled) throw new Error("conversation analysis is disabled"); + if (!ws.data.canEdit || ws.data.channelArchivedAt || deps.unavailable(room.id)) { + throw new Error("repository write access is required"); + } + let processor = room.plan && deps.runtime.processor(room.plan); + if (!processor) throw new Error("document is not open"); + let result = await processor.correct({ + actionId: frame.actionId, + threadId: frame.threadId, + expectedVersion: frame.expectedVersion, + change: frame.change, + }, { kind: "member", handle: ws.data.handle }); + reply(ws, frame.rid, { kind: "conversation-plan:correct", ts: 0, ...result }); + } catch (error) { + fail(ws, frame.rid, error instanceof Error ? error.message : "cannot correct card"); + } + return; + } + + case "conversation-plan:scoped-choice-save": + await handleScopedChoiceSave(frame, { + refreshAccess: () => deps.refreshAccess(), + conversationPlanEnabled: deps.enabled, + canEdit: () => ws.data.canEdit, + archived: () => !!ws.data.channelArchivedAt, + roomClosing: () => + !!room.closing || deps.unavailable(room.id) + || deps.unavailable(room.id), + actor: () => ({ kind: "member", handle: ws.data.handle }), + processor: () => room.plan && deps.runtime.processor(room.plan), + reply: (rid, result) => reply(ws, rid, result), + fail: (rid, message) => fail(ws, rid, message), + }); + return; + + case "conversation-plan:retry": { + try { + if (!deps.enabled) throw new Error("conversation analysis is disabled"); + if (!ws.data.canEdit || ws.data.channelArchivedAt || deps.unavailable(room.id)) { + throw new Error("repository write access is required"); + } + let processor = room.plan && deps.runtime.processor(room.plan); + if (!processor) throw new Error("document is not open"); + let result = await processor.retry(frame.actionId, frame.messageId, { + kind: "member", + handle: ws.data.handle, + }); + reply(ws, frame.rid, { kind: "conversation-plan:retry", ts: 0, ...result }); + } catch (error) { + fail(ws, frame.rid, error instanceof Error ? error.message : "cannot retry analysis"); + } + return; + } + + case "conversation-plan:retry-job": { + try { + if (!deps.enabled) throw new Error("conversation analysis is disabled"); + if ( + !ws.data.canEdit || ws.data.channelArchivedAt || room.closing + || deps.unavailable(room.id) || deps.unavailable(room.id) + ) throw new Error("repository write access is required"); + let opened = room.plan; + let jobs = opened && deps.runtime.jobs(opened); + if (!opened || !jobs) throw new Error("document is not open"); + let found = jobs.jobs().find(job => job.id === frame.jobId); + let claimant = found ? deps.chat() : undefined; + let previous = found && claimant + ? deps.runtime.contexts.remember(opened, found.trigger, claimant) + : undefined; + let queued: boolean; + try { + queued = await jobs.retry(frame.jobId); + } catch (error) { + if (found && claimant) { + deps.runtime.contexts.restore(opened, found.trigger, claimant, previous); + } + throw error; + } + if (!queued && found && claimant) { + deps.runtime.contexts.restore(opened, found.trigger, claimant, previous); + } + if ( + room.plan !== opened || room.closing || deps.unavailable(room.id) + || deps.unavailable(room.id) || ws.data.channelArchivedAt || !ws.data.canEdit + ) throw new Error("document is unavailable"); + reply(ws, frame.rid, { + kind: "conversation-plan:retry-job", + ts: 0, + jobId: frame.jobId, + queued, + }); + } catch (error) { + fail(ws, frame.rid, error instanceof Error ? error.message : "cannot retry job"); + } + return; + } + } +} diff --git a/apps/server/src/conversation-plan/research-commands.ts b/apps/server/src/conversation-plan/research-commands.ts new file mode 100644 index 00000000..8b7a904f --- /dev/null +++ b/apps/server/src/conversation-plan/research-commands.ts @@ -0,0 +1,108 @@ +import { fail, reply } from "../wire"; +import * as Service from "../plan/service"; +import type { ConversationPlan, Request } from "@chopin/protocol"; +import type { AuthorizationResult, Socket } from "../wire"; +import type * as Rooms from "../rooms"; +import type { Processor } from "./service"; +import type { createConversationRuntime } from "./runtime"; +import type { ResearchWorkspaceService } from "../research/service"; + +type ResearchCommand = Request; +type ResearchCommandDeps = { + enabled: boolean; + runtime: ReturnType; + research: () => ResearchWorkspaceService | undefined; + unavailable: (id: string) => boolean; + refreshAccess: (ws: Socket, force: boolean) => Promise; + start: ( + room: Rooms.Room, + ws: Socket, + opened: Service.Plan, + offer: ConversationPlan.ResearchOffer, + ) => Promise< + { execution: "started"; researchRequestId: string } | { execution: "pending-owner" } + >; +}; + +/** Reads recheck repository access; consent requires the existing writer admission. */ +export async function handleResearchCommand( + frame: ResearchCommand, + room: Rooms.Room, + ws: Socket, + deps: ResearchCommandDeps, +): Promise { + switch (frame.kind) { + case "conversation-plan:research": { + try { + if (!deps.enabled) throw new Error("conversation analysis is disabled"); + if ( + !ws.data.canEdit || ws.data.channelArchivedAt || room.closing + || deps.unavailable(room.id) || deps.unavailable(room.id) + ) throw new Error("repository write access is required"); + let opened = room.plan; + let processor = opened && deps.runtime.processor(opened); + if (!opened || !processor) throw new Error("document is not open"); + let result = await processor.researchConsent( + { + offerId: frame.offerId, + choice: frame.choice, + actionId: frame.actionId, + } as Parameters[0], + { + kind: "member", + handle: ws.data.handle, + }, + ws.data.principalId, + offer => deps.start(room, ws, opened, offer), + ); + reply(ws, frame.rid, { kind: "conversation-plan:research", ts: 0, ...result }); + } catch (error) { + fail( + ws, + frame.rid, + error instanceof Error ? error.message : "cannot act on research offer", + ); + } + return; + } + + case "conversation-plan:research-link": { + try { + let access = await deps.refreshAccess(ws, true); + if (access === "unavailable") { + fail(ws, frame.rid, "authorization is temporarily unavailable"); + return; + } + if (access === "denied") { + fail(ws, frame.rid, "authorization expired"); + ws.close(4403, "authorization expired"); + return; + } + if ( + typeof frame.offerId !== "string" || !frame.offerId + || frame.offerId.length > 200 + ) throw new Error("invalid research offer id"); + let opened = room.plan; + let research = deps.research(); + if (!opened || !research) throw new Error("document is not open"); + let offer = await Service.exclusive(opened, async () => { + let current = opened.conversationPlan?.researchOffers?.find(item => + item.id === frame.offerId + ); + return current && structuredClone(current); + }); + if (offer?.status !== "accepted") throw new Error("accepted research offer not found"); + let link = await research.acceptedOfferLink(room.id, offer); + reply(ws, frame.rid, { + kind: "conversation-plan:research-link", + ts: 0, + offerId: offer.id, + ...link, + }); + } catch (error) { + fail(ws, frame.rid, error instanceof Error ? error.message : "cannot read research offer"); + } + return; + } + } +} diff --git a/apps/server/src/conversation-plan/save-command.test.ts b/apps/server/src/conversation-plan/save-command.test.ts new file mode 100644 index 00000000..96d9eed7 --- /dev/null +++ b/apps/server/src/conversation-plan/save-command.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { handleScopedChoiceSave } from "./save-command"; + +type Member = Extract; +type SaveCommandFrame = ConversationPlan.SaveScopedChoice & { + rid: string; + source?: unknown; + quote?: string; + actor?: unknown; +}; +type RouteDeps = { + refreshAccess: () => Promise<"allowed" | "denied" | "unavailable">; + conversationPlanEnabled: boolean; + canEdit: boolean; + archived: boolean; + roomClosing: boolean; + actor: Member; + processor?: { + saveScopedChoice( + input: ConversationPlan.ScopedChoiceSave, + actor: Member, + ): Promise<{ eventId: string; revision: number }>; + }; + reply: (rid: string, frame: unknown) => void; + fail: (rid: string, message: string) => void; +}; +function frame(overrides: Partial = {}): SaveCommandFrame { + return { + kind: "conversation-plan:scoped-choice-save", + ts: 0, + rid: "save-rid", + actionId: "scoped-save:proposal-1:0", + threadId: "thread-1", + expectedVersion: 4, + proposalId: "proposal-1", + cardId: "card-1", + optionId: "option-1", + expectedLabel: "Lexical", + expectedGeneration: 0, + ...overrides, + }; +} + +function harness(options: { + access?: "allowed" | "denied" | "unavailable"; + conversationPlanEnabled?: boolean; + canEdit?: boolean; + archived?: boolean; + roomClosing?: boolean; + processor?: RouteDeps["processor"]; +} = {}) { + let calls: Array<{ input: ConversationPlan.ScopedChoiceSave; actor: Member }> = []; + let replies: Array<{ rid: string; frame: unknown; durable: boolean }> = []; + let failures: Array<{ rid: string; message: string }> = []; + let durable = false; + let processor: NonNullable = options.processor ?? { + saveScopedChoice: async (input, actor) => { + calls.push({ input, actor }); + durable = true; + return { eventId: "human:Rob:scoped-save:proposal-1:0", revision: 9 }; + }, + }; + let deps: RouteDeps = { + refreshAccess: async () => options.access ?? "allowed", + conversationPlanEnabled: options.conversationPlanEnabled ?? true, + canEdit: options.canEdit ?? true, + archived: options.archived ?? false, + roomClosing: options.roomClosing ?? false, + actor: { kind: "member", handle: "Rob" }, + processor, + reply: (rid, value) => replies.push({ rid, frame: value, durable }), + fail: (rid, message) => failures.push({ rid, message }), + }; + return { deps, calls, replies, failures, setDurable: (value: boolean) => durable = value }; +} + +function deferred() { + let resolve!: (value: T) => void; + let promise = new Promise(done => resolve = done); + return { promise, resolve }; +} + +async function until(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + if (condition()) return; + await Bun.sleep(1); + } + throw new Error("condition did not become true"); +} + +describe("scoped-choice Save wire route", () => { + test("passes only bounded request fields and replies after the durable save", async () => { + let gate = deferred(); + let setup = harness({ + processor: { + saveScopedChoice: async (input, actor) => { + setup.calls.push({ input, actor }); + await gate.promise; + setup.setDurable(true); + return { eventId: "human:Rob:scoped-save:proposal-1:0", revision: 9 }; + }, + }, + }); + let request = frame({ + source: { messageId: "forged", quote: "attacker text" }, + quote: "forged quote", + actor: { kind: "member", handle: "Mallory" }, + }); + let pending = handleScopedChoiceSave(request, setup.deps); + await until(() => setup.calls.length === 1); + expect(setup.calls).toEqual([{ + input: { + actionId: request.actionId, + threadId: request.threadId, + expectedVersion: request.expectedVersion, + proposalId: request.proposalId, + cardId: request.cardId, + optionId: request.optionId, + expectedLabel: request.expectedLabel, + expectedGeneration: request.expectedGeneration, + }, + actor: { kind: "member", handle: "Rob" }, + }]); + expect(setup.replies).toEqual([]); + expect(setup.failures).toEqual([]); + + gate.resolve(); + await pending; + expect(setup.replies).toEqual([{ + rid: request.rid, + frame: { + kind: "conversation-plan:scoped-choice-save", + ts: 0, + eventId: "human:Rob:scoped-save:proposal-1:0", + revision: 9, + }, + durable: true, + }]); + expect(setup.replies[0]!.frame).not.toHaveProperty("answers"); + }); + + test("authorization, write access, and room lifecycle gates block the processor", async () => { + let deniedCases = [ + { access: "denied" as const }, + { access: "unavailable" as const }, + { canEdit: false }, + { conversationPlanEnabled: false }, + { archived: true }, + { roomClosing: true }, + ]; + + for (let overrides of deniedCases) { + let setup = harness(overrides); + await handleScopedChoiceSave(frame(), setup.deps); + expect(setup.calls).toEqual([]); + expect(setup.replies).toEqual([]); + expect(setup.failures).toHaveLength(1); + } + }); + + test("a stale proposal error is returned as failure without a success reply", async () => { + let setup = harness({ + processor: { + saveScopedChoice: async (input, actor) => { + setup.calls.push({ input, actor }); + throw new Error("scoped choice card or proposal is stale"); + }, + }, + }); + let request = frame(); + await handleScopedChoiceSave(request, setup.deps); + expect(setup.calls).toHaveLength(1); + expect(setup.replies).toEqual([]); + expect(setup.failures).toEqual([{ + rid: request.rid, + message: "scoped choice card or proposal is stale", + }]); + }); +}); diff --git a/apps/server/src/conversation-plan/save-command.ts b/apps/server/src/conversation-plan/save-command.ts new file mode 100644 index 00000000..f60492ea --- /dev/null +++ b/apps/server/src/conversation-plan/save-command.ts @@ -0,0 +1,59 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { AuthorizationResult } from "../wire"; +import type { Processor } from "./service"; + +type Member = Extract; +type Current = T | (() => T); + +export type ScopedChoiceSaveRoute = { + refreshAccess: () => Promise; + conversationPlanEnabled: Current; + canEdit: Current; + archived: Current; + roomClosing: Current; + actor: Current; + processor?: Current | undefined>; + reply: (rid: string, frame: ConversationPlan.SavedScopedChoice) => void; + fail: (rid: string, message: string) => void; +}; + +function current(value: Current): T { + return typeof value === "function" ? (value as () => T)() : value; +} + +/** Receive only the authenticated socket's member identity and bounded command fields. */ +export async function handleScopedChoiceSave( + frame: ConversationPlan.SaveScopedChoice & { rid: string }, + deps: ScopedChoiceSaveRoute, +): Promise { + try { + let access = await deps.refreshAccess(); + if (access === "unavailable") throw new Error("authorization is temporarily unavailable"); + if (access !== "allowed") throw new Error("authorization expired"); + if (!current(deps.conversationPlanEnabled)) { + throw new Error("conversation analysis is disabled"); + } + if (!current(deps.canEdit) || current(deps.archived) || current(deps.roomClosing)) { + throw new Error("repository write access is required"); + } + let processor = deps.processor && current(deps.processor); + if (!processor) throw new Error("document is not open"); + let actor = current(deps.actor); + let result = await processor.saveScopedChoice({ + actionId: frame.actionId, + threadId: frame.threadId, + expectedVersion: frame.expectedVersion, + proposalId: frame.proposalId, + cardId: frame.cardId, + optionId: frame.optionId, + ...(frame.expectedLabel === undefined ? {} : { expectedLabel: frame.expectedLabel }), + expectedGeneration: frame.expectedGeneration, + }, { kind: "member", handle: actor.handle }); + deps.reply(frame.rid, { kind: "conversation-plan:scoped-choice-save", ts: 0, ...result }); + } catch (error) { + deps.fail( + frame.rid, + error instanceof Error ? error.message : "cannot save scoped choice", + ); + } +} diff --git a/apps/server/src/conversation-plan/service-opening-memory.test-fixtures.ts b/apps/server/src/conversation-plan/service-opening-memory.test-fixtures.ts new file mode 100644 index 00000000..8db7ea4e --- /dev/null +++ b/apps/server/src/conversation-plan/service-opening-memory.test-fixtures.ts @@ -0,0 +1,66 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan, storedQuestion } from "../testing/plan"; + +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + let promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +export async function storedLegacy() { + let id = ulid(); + let definition = Questions.identify({ + questions: [{ + header: "Storage", + question: "Where should data live?", + multiple: false, + options: [{ label: "PostgreSQL", description: "Shared." }], + }], + }); + let question = definition.questions[0]!; + let document = await room.create(); + let source: string; + try { + room.insertQuestionnaire(document, { + id, + questions: [{ + id: question.id, + header: question.header, + prompt: question.question, + multiple: question.multiple, + options: question.options, + }], + }); + source = room.project(document); + } finally { + document.doc.destroy(); + } + let context = await openPlan(source, { + questions: [{ + id, + definition, + status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }], + openQuestions: [{ + id, + definition: Question.decision(definition), + widget: id, + model: storedQuestion(Question.decision(definition)), + revision: 0, + }], + }); + await Service.close(context.plan); + return { ...context, id, source }; +} diff --git a/apps/server/src/conversation-plan/service-opening-memory.test.ts b/apps/server/src/conversation-plan/service-opening-memory.test.ts new file mode 100644 index 00000000..8622aef3 --- /dev/null +++ b/apps/server/src/conversation-plan/service-opening-memory.test.ts @@ -0,0 +1,337 @@ +import { expect, test } from "bun:test"; +import * as Service from "../plan/service"; +import * as room from "../plan/room"; +import { backfillPlannerAskThreads } from "../questions/backfill"; +import { prepareOpenedPlan } from "./service-opening"; +import { deferred, storedLegacy } from "./service-opening-memory.test-fixtures"; +import type { Room } from "../rooms"; + +function openingRoom(id: string): Room { + return { id, members: new Map() }; +} + +test("real Memory migration commits before shared opening consumers and attachment see the plan", async () => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let entered = deferred(); + let release = deferred(); + let commit = context.storage.collaboration.commit; + let attachments: Service.Plan[] = []; + context.storage.collaboration.commit = async input => { + entered.resolve(); + await release.promise; + return commit(input); + }; + try { + target.opening = prepareOpenedPlan(target, opened, async () => { + expect(await backfillPlannerAskThreads(opened)).toBe(1); + }).then(plan => { + attachments.push(plan); + return plan; + }); + let second = target.opening.then(() => target.plan); + await entered.promise; + expect(target.plan).toBeUndefined(); + expect(attachments).toEqual([]); + expect(opened.records.get(context.id)?.threadId).toBeUndefined(); + expect(room.questionnaireProjections(opened.document)[0]?.thread).toBeUndefined(); + release.resolve(); + let [first, concurrent] = await Promise.all([target.opening, second]); + expect(first).toBe(opened); + expect(concurrent).toBe(opened); + expect(attachments).toEqual([opened]); + expect(opened.records.get(context.id)?.threadId).toBe(`planner-ask:${context.id}`); + } finally { + release.resolve(); + context.storage.collaboration.commit = commit; + await Service.close(opened); + } + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.records.get(context.id)?.threadId).toBe(`planner-ask:${context.id}`); + expect(room.questionnaireProjections(restored.document)[0]?.thread).toBe( + `planner-ask:${context.id}`, + ); + expect(await backfillPlannerAskThreads(restored)).toBe(0); + } finally { + await Service.close(restored); + } +}); + +test("close intent during an admitted Memory migration prevents late exposure and disposes the unopened plan", async () => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let entered = deferred(); + let release = deferred(); + let commit = context.storage.collaboration.commit; + let held = false; + let destroyed = 0; + let attached = 0; + let commits = 0; + opened.document.doc.on("destroy", () => { + destroyed++; + }); + context.storage.collaboration.commit = async input => { + commits++; + if (!held) { + held = true; + entered.resolve(); + await release.promise; + } + return commit(input); + }; + try { + target.opening = prepareOpenedPlan(target, opened, async () => { + await backfillPlannerAskThreads(opened); + }).then(plan => { + attached++; + return plan; + }); + let first = target.opening.then(() => undefined, error => error); + let second = target.opening.then(() => target.plan, error => error); + await entered.promise; + expect(target.plan).toBeUndefined(); + // Matches the lock ordering: closing intent is immediate; its queued action awaits opening. + target.closing = target.opening.then(async () => { + let plan = target.plan; + target.plan = undefined; + if (plan) await Service.close(plan); + }, () => {}); + release.resolve(); + let [error, concurrentError] = await Promise.all([first, second, target.closing]); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("document is unavailable"); + expect(concurrentError).toBe(error); + expect(target.plan).toBeUndefined(); + expect(attached).toBe(0); + expect(opened.persistence.closing).toBe(true); + expect(destroyed).toBe(1); + expect(commits).toBe(1); + } finally { + release.resolve(); + context.storage.collaboration.commit = commit; + } + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + // The already-admitted commit settled before cleanup; it is not rolled back by close intent. + expect(restored.records.get(context.id)?.threadId).toBe(`planner-ask:${context.id}`); + expect(await backfillPlannerAskThreads(restored)).toBe(0); + } finally { + await Service.close(restored); + } +}); + +test("failed real migration commit disposes the unpublished service and a later Memory reopen can retry", async () => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let commit = context.storage.collaboration.commit; + let failure = new Error("backfill store rejected"); + let failed = false; + let destroyed = 0; + let commits = 0; + opened.document.doc.on("destroy", () => { + destroyed++; + }); + context.storage.collaboration.commit = input => { + commits++; + if (!failed) { + failed = true; + return Promise.reject(failure); + } + return commit(input); + }; + try { + await expect( + prepareOpenedPlan(target, opened, () => backfillPlannerAskThreads(opened).then(() => {})), + ).rejects.toBe(failure); + expect(target.plan).toBeUndefined(); + expect(opened.persistence.closing).toBe(true); + expect(destroyed).toBe(1); + expect(commits).toBe(1); + } finally { + context.storage.collaboration.commit = commit; + } + let retry = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(retry.records.get(context.id)?.threadId).toBeUndefined(); + expect(retry.conversationPlan.events).toEqual([]); + expect(room.project(retry.document)).toBe(context.source); + await prepareOpenedPlan(target, retry, () => + backfillPlannerAskThreads(retry).then(count => { + expect(count).toBe(1); + })); + expect(target.plan).toBe(retry); + expect(retry.records.get(context.id)?.threadId).toBe(`planner-ask:${context.id}`); + } finally { + await Service.close(retry); + } +}); + +test("persistent storage failure releases unpublished resources without a cleanup commit and permits retry", async () => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let commit = context.storage.collaboration.commit; + let failure = new Error("storage remains unavailable"); + let destroyed = 0; + let commits = 0; + let presenceDestroyed = 0; + opened.document.doc.on("destroy", () => { + destroyed++; + }); + opened.presence.doc.on("destroy", () => { + presenceDestroyed++; + }); + expect(opened.questions.open.has(context.id)).toBe(true); + context.storage.collaboration.commit = () => { + commits++; + return Promise.reject(failure); + }; + try { + await expect(prepareOpenedPlan(target, opened, async () => { + await backfillPlannerAskThreads(opened); + })).rejects.toBe(failure); + expect(target.plan).toBeUndefined(); + expect(opened.persistence.closing).toBe(true); + expect(destroyed).toBe(1); + expect(presenceDestroyed).toBe(1); + expect(opened.questions.open.size).toBe(0); + expect(opened.chat.closed).toBe(true); + expect(commits).toBe(1); + } finally { + context.storage.collaboration.commit = commit; + } + let retry = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(retry.records.get(context.id)?.threadId).toBeUndefined(); + expect(retry.conversationPlan.events).toEqual([]); + expect(room.project(retry.document)).toBe(context.source); + await prepareOpenedPlan(target, retry, async () => { + expect(await backfillPlannerAskThreads(retry)).toBe(1); + }); + expect(target.plan).toBe(retry); + expect(retry.records.get(context.id)?.threadId).toBe(`planner-ask:${context.id}`); + } finally { + await Service.close(retry); + } +}); + +test("close intent while the actual channel lookup is held prevents migration admission", async () => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let entered = deferred(); + let release = deferred(); + let get = context.storage.channels.get; + let commit = context.storage.collaboration.commit; + let commits = 0; + let destroyed = 0; + let attached = 0; + opened.document.doc.on("destroy", () => { + destroyed++; + }); + context.storage.channels.get = async id => { + entered.resolve(); + await release.promise; + return get(id); + }; + context.storage.collaboration.commit = input => { + commits++; + return commit(input); + }; + try { + target.opening = prepareOpenedPlan(target, opened, async () => { + let channel = await context.storage.channels.get(context.channel.id); + // The actual main caller owns this guard after its awaited channel lookup. + if (target.closing) throw new Error("document is unavailable"); + if (!channel?.archivedAt) await backfillPlannerAskThreads(opened); + }).then(plan => { + attached++; + return plan; + }); + let failed = target.opening.then(() => undefined, error => error); + let concurrent = target.opening.then(() => target.plan, error => error); + await entered.promise; + target.closing = target.opening.then(async () => { + if (target.plan) await Service.close(target.plan); + }, () => {}); + release.resolve(); + let [error, sameError] = await Promise.all([failed, concurrent, target.closing]); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("document is unavailable"); + expect(sameError).toBe(error); + expect(target.plan).toBeUndefined(); + expect(attached).toBe(0); + expect(commits).toBe(0); + expect(destroyed).toBe(1); + expect(opened.persistence.closing).toBe(true); + } finally { + release.resolve(); + context.storage.channels.get = get; + context.storage.collaboration.commit = commit; + } + let retry = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(retry.records.get(context.id)?.threadId).toBeUndefined(); + expect(retry.conversationPlan.events).toEqual([]); + expect(room.project(retry.document)).toBe(context.source); + target.closing = undefined; + await prepareOpenedPlan(target, retry, async () => { + expect(await backfillPlannerAskThreads(retry)).toBe(1); + }); + expect(target.plan).toBe(retry); + } finally { + await Service.close(retry); + } +}); + +test.each([1, 2])("a %i-hop closing microtask cannot publish after close intent", async hops => { + let context = await storedLegacy(); + let opened = await Service.open(context.channel.id, context.backend, context.server); + let target = openingRoom(context.channel.id); + let publishedBeforeClose: boolean | undefined; + let destroyed = 0; + let attached = 0; + opened.document.doc.on("destroy", () => { + destroyed++; + }); + let closeIntent = () => { + publishedBeforeClose = target.plan === opened; + target.closing = target.opening!.then(async () => { + let plan = target.plan; + target.plan = undefined; + if (plan) await Service.close(plan); + }, () => {}); + }; + target.opening = prepareOpenedPlan(target, opened, () => { + if (hops === 1) queueMicrotask(closeIntent); + else queueMicrotask(() => queueMicrotask(closeIntent)); + }).then(plan => { + attached++; + return plan; + }); + let result = await target.opening.then( + plan => ({ plan, error: undefined }), + error => ({ plan: undefined, error }), + ); + await target.closing; + expect(publishedBeforeClose).toBeDefined(); + if (hops === 1) expect(publishedBeforeClose).toBe(false); + if (publishedBeforeClose) { + // Synchronous publication preceded intent: normal queued close owns this plan. + expect(result.plan).toBe(opened); + expect(result.error).toBeUndefined(); + expect(attached).toBe(1); + } else { + expect(result.plan).toBeUndefined(); + expect(result.error).toBeInstanceOf(Error); + expect(result.error.message).toBe("document is unavailable"); + expect(attached).toBe(0); + } + expect(target.plan).toBeUndefined(); + expect(opened.persistence.closing).toBe(true); + expect(destroyed).toBe(1); +}); diff --git a/apps/server/src/conversation-plan/service-opening-order-memory.test.ts b/apps/server/src/conversation-plan/service-opening-order-memory.test.ts new file mode 100644 index 00000000..9843eb67 --- /dev/null +++ b/apps/server/src/conversation-plan/service-opening-order-memory.test.ts @@ -0,0 +1,290 @@ +import { expect, test } from "bun:test"; +import type { Chat as ChatWire, ConversationPlan } from "@chopin/protocol"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { greetJoinedPlan, readyPlan, recoverAttachedPlan } from "./service-opening"; +import { deferred } from "./service-opening-memory.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +function memberSocket() { + let frames: Array> = []; + let ws = { + data: { handle: "test", principalId: "U_test" }, + send(value: string) { + frames.push(JSON.parse(value)); + }, + } as unknown as Socket; + return { ws, frames }; +} + +test("a queued real Memory greeting refuses a retired room before sending any snapshot", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let target: Room = { id: plan.id, plan, members: new Map() }; + let entered = deferred(); + let release = deferred(); + let joiner = memberSocket(); + let held = Plan.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + try { + await entered.promise; + let greeting = greetJoinedPlan( + plan, + joiner.ws, + { + kind: "plan:open", + ts: 0, + rid: "retired-join", + }, + true, + () => target.plan === plan, + ); + let result = greeting.then(() => undefined, error => error); + await Bun.sleep(2); + expect(joiner.frames).toEqual([]); + target.plan = undefined; + release.resolve(); + await held; + let error = await result; + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("document is unavailable"); + expect(joiner.frames).toEqual([]); + } finally { + release.resolve(); + await held; + await Plan.close(plan); + } +}); + +test("a published plan waits for actual runtime replacement drain before binding and sending", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let target: Room = { id: plan.id, plan, members: new Map() }; + let firstId = crypto.randomUUID(); + let requestId = crypto.randomUUID(); + let interpreting = deferred(); + let aborted = deferred(); + let release = deferred(); + let secondInterpreting = deferred(); + let secondRelease = deferred(); + let firstRuns = 0; + let errors: unknown[] = []; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + interpret: async (input, signal) => { + if (input.message.id === firstId && firstRuns++ === 0) { + interpreting.resolve(); + signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + // Only the injected inference boundary is held, even after observing abort. + await release.promise; + if (signal.aborted) throw signal.reason; + } + if (input.message.id === requestId) { + secondInterpreting.resolve(); + await secondRelease.promise; + } + return { + events: [], + analysis: { + status: "unlinked", + questionSetVersion: "test", + modelVersion: "offline", + passes: [], + }, + }; + }, + onError: error => errors.push(error), + }); + let context = () => + runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: target.id, + config: { agent: false } as Chat.Room["config"], + auth: {} as Chat.Room["auth"], + claimantSessionId: "test-session", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + persist: () => Plan.persist(plan), + }); + let sending: Promise | undefined; + try { + await runtime.attach(target, plan, false); + await Chat.send(context(), memberSocket().ws, { + kind: "chat:send", + ts: 0, + rid: "initial-send", + requestId: firstId, + to: "room", + text: "Use an optional outline.", + }); + await interpreting.promise; + // The actual replacement attach waits for the old processor's held idle drain. + // This fixture supplies main's shared opening promise, not its socket/auth execution. + target.opening = runtime.attach(target, plan, false).then(() => plan); + await aborted.promise; + let sender = memberSocket(); + let bound = false; + sending = (async () => { + let current = await readyPlan(target); + expect(current).toBe(plan); + let boundContext = context(); + bound = true; + expect(boundContext.commitRoomMessage).toBeFunction(); + await Chat.send(boundContext, sender.ws, { + kind: "chat:send", + ts: 0, + rid: "published-send", + requestId, + to: "room", + text: "Should we use an optional outline?", + }); + })(); + await Bun.sleep(2); + expect(target.plan).toBe(plan); + expect(runtime.processor(plan)).toBeUndefined(); + expect(bound).toBe(false); + expect(sender.frames).toEqual([]); + expect(plan.chat.entries.map(entry => entry.id)).toEqual([firstId]); + release.resolve(); + await Promise.all([target.opening, sending, secondInterpreting.promise]); + expect(runtime.processor(plan)).toBeDefined(); + expect(bound).toBe(true); + expect(sender.frames).toContainEqual(expect.objectContaining({ + kind: "chat:send", + rid: "published-send", + id: requestId, + })); + let loaded = (await opened.storage.collaboration.load(plan.id, new Date()))!; + let sidecar = (loaded.sidecar ?? loaded.snapshot!.sidecar) as unknown as { + transcript: ChatWire.Entry[]; + conversationPlan: ConversationPlan.State; + }; + expect(sidecar.transcript.map(entry => entry.id)).toEqual([firstId, requestId]); + expect(sidecar.conversationPlan.queue.filter(item => item.messageId === requestId)).toEqual([ + { messageId: requestId, status: "pending", attempts: 0 }, + ]); + } finally { + release.resolve(); + secondRelease.resolve(); + await target.opening; + await sending; + await runtime.stop(plan); + await Plan.close(plan); + } + expect(errors).toEqual([]); +}); + +test("recovery waits for the real held runtime attachment before attaching again", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let target: Room = { id: plan.id, plan, members: new Map() }; + let entered = deferred(); + let aborted = deferred(); + let release = deferred(); + let first = true; + let recovering = false; + let attachments = 0; + let errors: unknown[] = []; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => { + attachments++; + return opened.server; + }, + unavailable: () => false, + interpret: async (_input, signal) => { + if (first) { + first = false; + entered.resolve(); + signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + await release.promise; + if (signal.aborted) throw signal.reason; + } + return { + events: [], + analysis: { + status: "unlinked", + questionSetVersion: "test", + modelVersion: "offline", + passes: [], + }, + }; + }, + onError: error => errors.push(error), + }); + let recovery: Promise | undefined; + try { + await runtime.attach(target, plan, false); + await Chat.send( + runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: target.id, + config: { agent: false } as Chat.Room["config"], + auth: {} as Chat.Room["auth"], + claimantSessionId: "test-session", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + persist: () => Plan.persist(plan), + }), + memberSocket().ws, + { + kind: "chat:send", + ts: 0, + rid: "recovery-send", + requestId: crypto.randomUUID(), + to: "room", + text: "Use an optional outline.", + }, + ); + await entered.promise; + target.opening = runtime.attach(target, plan, false).then(() => plan); + await aborted.promise; + recovery = recoverAttachedPlan(target).then(async current => { + recovering = true; + if (current) await runtime.attach(target, current, false); + return current; + }); + await Bun.sleep(2); + expect(target.plan).toBe(plan); + expect(recovering).toBe(false); + expect(attachments).toBe(1); + expect(runtime.processor(plan)).toBeUndefined(); + release.resolve(); + let [, recovered] = await Promise.all([target.opening, recovery]); + expect(recovered).toBe(plan); + expect(recovering).toBe(true); + expect(attachments).toBe(3); + expect(runtime.processor(plan)).toBeDefined(); + } finally { + release.resolve(); + await target.opening; + await recovery; + await runtime.stop(plan); + await Plan.close(plan); + } + expect(errors).toEqual([]); +}); + +test("recovery after a rejected opening keeps a useful published Memory plan and tolerates none", async () => { + let opened = await openPlan(); + let target: Room = { id: opened.plan.id, plan: opened.plan, members: new Map() }; + try { + target.opening = Promise.reject(new Error("attachment failed")); + expect(await recoverAttachedPlan(target)).toBe(opened.plan); + target.plan = undefined; + target.opening = Promise.reject(new Error("opening failed before publication")); + expect(await recoverAttachedPlan(target)).toBeUndefined(); + expect(await recoverAttachedPlan(undefined)).toBeUndefined(); + } finally { + await Plan.close(opened.plan); + } +}); diff --git a/apps/server/src/conversation-plan/service-opening.test.ts b/apps/server/src/conversation-plan/service-opening.test.ts new file mode 100644 index 00000000..1bd597bc --- /dev/null +++ b/apps/server/src/conversation-plan/service-opening.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import type { Server } from "bun"; +import type { + Chat as ChatWire, + ConversationPlan, + Plan as PlanWire, + Request, +} from "@chopin/protocol"; +import * as Chat from "../chat/service"; +import * as Service from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import type { Socket, SocketData } from "../wire"; +import { createProcessor, type Processor } from "./service"; +import * as Jobs from "./jobs"; +import { + greetJoinedPlan, + publishOpenedPlan, + readyPlan, + recoverOpenedPlan, +} from "./service-opening"; + +function deferred() { + let resolve!: () => void; + let promise = new Promise(done => resolve = done); + return { promise, resolve }; +} + +async function hosted() { + let now = new Date("2026-08-13T12:00:00.000Z"); + let storage = new MemoryStorage(); + await storage.users.put({ id: "U_ana", login: "ana", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "team", + repositoryName: "demo", + title: "Demo", + createdBy: "U_ana", + now, + }); + let lease = await storage.leases.acquire("writer", "test", 60_000); + if (!lease) throw new Error("test lease missing"); + let server = { publish() {} } as unknown as Server; + let plan = await Service.open(channel.id, { + storage, + lease: () => lease, + fatal: () => {}, + }, server); + return { channel, storage, server, plan }; +} + +function socket() { + let frames: Array> = []; + let ws = { + data: { handle: "ana", principalId: "U_ana" }, + send(value: string) { + frames.push(JSON.parse(value) as Record); + }, + } as unknown as Socket; + return { ws, frames }; +} + +function processorFor(plan: Service.Plan, active: () => boolean): Processor { + return createProcessor({ + plan, + exclusive: action => Service.exclusive(plan, action), + persist: () => Service.persistExclusive(plan), + publish: () => {}, + active, + }); +} + +describe("conversation-plan open and join ordering", () => { + test("failed archive suspension waits for a held opening before reattaching jobs", async () => { + let context = await hosted(); + let room: { plan?: Service.Plan; opening?: Promise } = {}; + let entered = deferred(); + let release = deferred(); + let archiving = true; + let attachments = 0; + room.opening = publishOpenedPlan(room, context.plan, async () => { + entered.resolve(); + await release.promise; + if (!archiving) attachments++; + }); + await entered.promise; + let recovered = Promise.reject(new Error("summary suspension failed")).catch(async () => { + let opened = await recoverOpenedPlan(room); + archiving = false; + if (opened) attachments++; + }); + await Bun.sleep(2); + expect(room.plan).toBeUndefined(); + expect(attachments).toBe(0); + release.resolve(); + await recovered; + expect(room.plan).toBe(context.plan); + expect(attachments).toBe(1); + await Service.close(context.plan); + }); + + test("a joining member sees the durable Planner job queue in the locked snapshot", async () => { + let context = await hosted(); + context.plan.conversationPlanJobs = Jobs.enqueue([], { + kind: "heading", + target: "document", + trigger: "m1", + }, "2026-09-25T10:00:00.000Z"); + await Service.persist(context.plan); + let joiner = socket(); + await greetJoinedPlan( + context.plan, + joiner.ws, + { + kind: "plan:open", + ts: 0, + rid: "open-jobs", + }, + true, + () => true, + ); + expect(joiner.frames.find(frame => frame.kind === "conversation-plan:snapshot")?.jobs) + .toEqual(context.plan.conversationPlanJobs); + await Service.close(context.plan); + }); + + test("a send during deferred open waits for processor attachment and commits its pending ID", async () => { + let context = await hosted(); + let gate = deferred(); + let room: { plan?: Service.Plan; opening?: Promise } = {}; + let processor: Processor | undefined; + room.opening = publishOpenedPlan(room, context.plan, async () => { + await gate.promise; + processor = processorFor(context.plan, () => room.plan === context.plan); + }); + let sender = socket(); + let message: Request = { + kind: "chat:send", + ts: 0, + rid: "send-1", + requestId: crypto.randomUUID(), + text: "Should we use an optional outline?", + to: "room", + }; + let sending = (async () => { + let opened = await readyPlan(room); + if (!opened) throw new Error("plan did not open"); + await Chat.send( + { + chat: opened.chat, + plan: opened, + server: context.server, + room: context.channel.id, + config: { agent: false } as Chat.Room["config"], + auth: {} as Chat.Room["auth"], + claimantSessionId: "session", + repository: { + id: "R_test", + owner: "team", + name: "demo", + defaultBranch: "main", + }, + persist: () => Service.persist(opened), + commitRoomMessage: entry => processor!.accept(entry), + }, + sender.ws, + message, + ); + })(); + await Bun.sleep(2); + expect(room.plan).toBeUndefined(); + expect(sender.frames).toEqual([]); + gate.resolve(); + await Promise.all([room.opening, sending]); + expect(sender.frames).toContainEqual(expect.objectContaining({ + kind: "chat:send", + rid: "send-1", + id: message.requestId, + })); + let loaded = await context.storage.collaboration.load(context.channel.id, new Date()); + let sidecar = loaded!.sidecar as Record; + expect((sidecar.transcript as ChatWire.Entry[]).map(entry => entry.id)).toEqual([ + message.requestId, + ]); + expect((sidecar.conversationPlan as { queue: ConversationPlan.QueueItem[] }).queue) + .toEqual([{ messageId: message.requestId, status: "pending", attempts: 0 }]); + processor!.stop(); + await Service.close(context.plan); + }); + + for (let fails of [false, true]) { + test(`joining while a message commit is ${fails ? "failing" : "delayed"} sees only committed state`, async () => { + let context = await hosted(); + let processor = processorFor(context.plan, () => true); + let entered = deferred(); + let release = deferred(); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + entered.resolve(); + await release.promise; + if (fails) throw new Error("storage failed"); + return original(input); + }; + let accepting = processor.accept({ + id: "message-1", + author: { kind: "member", handle: "ana" }, + text: "Use an optional outline.", + ts: 1, + }); + await entered.promise; + expect(context.plan.chat.entries.map(entry => entry.id)).toEqual(["message-1"]); + let joiner = socket(); + let opening: Request = { + kind: "plan:open", + ts: 0, + rid: "open-1", + }; + let greeting = greetJoinedPlan(context.plan, joiner.ws, opening, true, () => true); + await Bun.sleep(2); + expect(joiner.frames).toEqual([]); + release.resolve(); + if (fails) await expect(accepting).rejects.toThrow("storage failed"); + else await accepting; + await greeting; + let history = joiner.frames.find(frame => frame.kind === "chat:history")!; + let snapshot = joiner.frames.find(frame => frame.kind === "conversation-plan:snapshot")!; + expect((history.entries as ChatWire.Entry[]).map(entry => entry.id)) + .toEqual(fails ? [] : ["message-1"]); + expect((snapshot.state as { queue: ConversationPlan.QueueItem[] }).queue) + .toEqual(fails ? [] : [{ messageId: "message-1", status: "pending", attempts: 0 }]); + context.storage.collaboration.commit = original; + processor.stop(); + await Service.close(context.plan); + }); + } +}); diff --git a/apps/server/src/conversation-plan/service-opening.ts b/apps/server/src/conversation-plan/service-opening.ts new file mode 100644 index 00000000..25763de8 --- /dev/null +++ b/apps/server/src/conversation-plan/service-opening.ts @@ -0,0 +1,111 @@ +import * as Chat from "../chat/service"; +import * as Comments from "../comments/service"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { tell } from "../wire"; + +import type { Plan as Wire, Request } from "@chopin/protocol"; +import type { Socket } from "../wire"; +import type { Room } from "../rooms"; + +type OpeningRoom

= { plan?: P; opening?: Promise

}; + +/** Complete all setup before exposing a plan to concurrent socket frames. */ +export async function publishOpenedPlan

( + room: OpeningRoom

, + opened: P, + prepare: () => Promise | void, +): Promise

{ + await prepare(); + room.plan = opened; + return opened; +} + +/** Preparation owns an unpublished document until close intent has been checked. */ +export async function prepareOpenedPlan( + room: Room, + opened: Service.Plan, + prepare: () => Promise | void, +): Promise { + try { + if (room.closing) throw new Error("document is unavailable"); + await prepare(); + if (room.closing) throw new Error("document is unavailable"); + room.plan = opened; + return opened; + } catch (error) { + try { + await Service.abortOpening(opened); + } catch (cleanupError) { + let failure = new AggregateError( + [error, cleanupError], + "document preparation and cleanup failed", + { + cause: cleanupError, + }, + ); + throw failure; + } + throw error; + } +} + +/** A chat send racing first open must wait for processor attachment. */ +export async function readyPlan

(room: OpeningRoom

): Promise

{ + if (room.opening) await room.opening; + return room.plan; +} + +/** An archive that failed early still has to observe a concurrent first open. */ +export async function recoverOpenedPlan

( + room: OpeningRoom

| undefined, +): Promise

{ + if (!room) return undefined; + if (!room.plan) { + try { + await room.opening; + } catch { + // A failed open left no plan or job coordinator to reattach. + } + } + return room.plan; +} + +/** Read the transcript and prototype together after all earlier commits settle. */ +export function greetJoinedPlan( + opened: Service.Plan, + ws: Socket, + frame: Request, + conversationPlan: boolean, + current: () => boolean, +): Promise { + return Service.exclusive(opened, async () => { + if (!current()) throw new Error("document is unavailable"); + Service.greet(opened, ws, frame); + Questions.greet(opened, ws); + Comments.greet(opened, ws); + Chat.greet(opened.chat, ws); + if (conversationPlan) { + tell(ws, { + kind: "conversation-plan:snapshot", + ts: 0, + state: opened.conversationPlan, + jobs: opened.conversationPlanJobs, + }); + } + }); +} + +/** Recovery must also settle an attachment after the plan has been published. */ +export async function recoverAttachedPlan

( + room: OpeningRoom

| undefined, +): Promise

{ + if (room?.opening) { + try { + await room.opening; + } catch { + // A failed first open must not replace the archive outcome. + } + } + return recoverOpenedPlan(room); +} diff --git a/apps/server/src/harness/tool-names.ts b/apps/server/src/harness/tool-names.ts index e87aea3e..8ac9062f 100644 --- a/apps/server/src/harness/tool-names.ts +++ b/apps/server/src/harness/tool-names.ts @@ -18,6 +18,7 @@ export const PLANNER_TOOL_NAMES = [ "repository_history", "list_pull_requests", "pull_request_read", + "revise_open_decision", ]; function jobNames(own: string): readonly string[] { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index e83433e7..bec2f69d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -16,6 +16,16 @@ import { registerAuthRoutes } from "./auth/routes"; import * as Chat from "./chat/service"; import { CHAT_CAPABILITIES, incomingFrame } from "./chat/incoming"; import { ReferenceService } from "./chat/references"; +import { createConversationRuntime } from "./conversation-plan/runtime"; +import { + greetJoinedPlan, + prepareOpenedPlan, + readyPlan, + recoverAttachedPlan, +} from "./conversation-plan/service-opening"; +import { handleConversationCommand } from "./conversation-plan/commands"; +import { handleResearchCommand } from "./conversation-plan/research-commands"; +import { startAcceptedResearch } from "./conversation-plan/accepted-research"; import { registerChannelRoutes } from "./channels/routes"; import * as Comments from "./comments/service"; import { proxy, serve } from "./client"; @@ -36,11 +46,13 @@ import * as Service from "./plan/service"; import * as Inject from "./questions/inject"; import * as Marks from "./comments/inject"; import * as Questions from "./questions/service"; +import { backfillPlannerAskThreads } from "./questions/backfill"; import { registerResearchWorkspaceRoutes } from "./research/routes"; import { ResearchWorkspaceError, ResearchWorkspaceService } from "./research/service"; import { placeResearchReference as placeResearch } from "./research/placement"; import * as Rooms from "./rooms"; import { admit } from "./socket/admission"; +import { refreshAuthorization } from "./socket/authorization"; import { StorageError } from "./storage/errors"; import { createStorage } from "./storage/registry"; import { broadcast, fail, relay, reply, tell, topic } from "./wire"; @@ -92,6 +104,11 @@ let documentLocks = new Map>(); let documentTransitions = new Map>(); let archivingChannels = new Set(); let deletingChannels = new Set(); +let conversationRuntime = createConversationRuntime({ + config, + server: () => server, + unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), +}); function withDocumentLock(channelId: string, action: () => Promise): Promise { let previous = documentLocks.get(channelId) ?? Promise.resolve(); @@ -184,14 +201,29 @@ function scheduleResearchRecovery(deferred: number): void { async function plan(room: Rooms.Room, server: Server): Promise { if (room.closing) await room.closing; if (deletingChannels.has(room.id)) throw new Error("document is unavailable"); - if (room.plan) return room.plan; + if (room.opening) { + let opened = await room.opening; + conversationRuntime.wake(opened); + return opened; + } + if (room.plan) { + conversationRuntime.wake(room.plan); + return room.plan; + } let backend = documentBackend(); let opening = room.opening ??= withDocumentLock(room.id, async () => { if (deletingChannels.has(room.id)) throw new Error("document is unavailable"); if (room.plan) return room.plan; let opened = await Service.open(room.id, backend, server); - room.plan = opened; - let channel = await storage.channels.get(room.id); + let channel: ChannelRecord | undefined; + await prepareOpenedPlan(room, opened, async () => { + channel = await storage.channels.get(room.id); + if (room.closing) throw new Error("document is unavailable"); + if (config.conversationPlan && !channel?.archivedAt) { + await backfillPlannerAskThreads(opened); + } + }); + await conversationRuntime.attach(room, opened, !!channel?.archivedAt); if (!channel?.archivedAt) { if (summaryCoordinator) void summaryCoordinator.ensure(room.id).catch(() => {}); if (Inject.enabled()) Inject.ask(opened, server, room.id); @@ -200,7 +232,9 @@ async function plan(room: Rooms.Room, server: Server): Promise): Promise Service.persist(room.plan!), + persist: () => Service.persist(opened), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), jobs: config.backgroundJobs ? jobService : undefined, @@ -255,7 +290,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { }; } : undefined, - }; + }); } async function closeRoom(room: Rooms.Room, force = false): Promise { @@ -264,7 +299,11 @@ async function closeRoom(room: Rooms.Room, force = false): Promise { if (!force && room.members.size > 0) return; let held = room.plan; room.plan = undefined; - if (held) await Service.close(held); + if (held) { + let stopped = conversationRuntime.stop(held); + await stopped; + await Service.close(held); + } if (force || room.members.size === 0) Rooms.forget(room); }); room.closing = closing; @@ -320,12 +359,17 @@ async function receive(ws: Socket, raw: string): Promise { case "plan:open": { try { let opened = await plan(room, server); - Service.greet(opened, ws, frame); - // Anything still unanswered, so a joiner sees the sidecar the - // others are already looking at, and everything said so far. - Questions.greet(opened, ws); - Comments.greet(opened, ws); - Chat.greet(opened.chat, ws); + await greetJoinedPlan( + opened, + ws, + frame, + !!config.conversationPlan, + () => + Rooms.get(room.id) === room && room.plan === opened + && !room.closing && !deletingChannels.has(room.id) + && !ws.data.closed && ws.data.room === room.id + && room.members.get(ws.data.client) === ws, + ); } catch (err) { fail(ws, frame.rid, err instanceof Error ? err.message : "cannot open plan"); } @@ -345,7 +389,39 @@ async function receive(ws: Socket, raw: string): Promise { return; case "chat:send": - if (room.plan) await Chat.send(chat(room, ws), ws, frame); + try { + let opened = await readyPlan(room); + if (!opened) { + fail(ws, frame.rid, "document is unavailable"); + return; + } + let access = await refreshAccess(ws); + if (access === "unavailable") { + fail(ws, frame.rid, "authorization is temporarily unavailable"); + return; + } + if (access === "denied") { + fail(ws, frame.rid, "authorization expired"); + ws.close(4403, "authorization expired"); + return; + } + if (!ws.data.canEdit) { + fail(ws, frame.rid, "repository write access is required"); + return; + } + if ( + Rooms.get(room.id) !== room || room.plan !== opened + || room.closing || archivingChannels.has(room.id) || deletingChannels.has(room.id) + || ws.data.closed || ws.data.room !== room.id + || room.members.get(ws.data.client) !== ws + ) { + fail(ws, frame.rid, "document is unavailable"); + return; + } + await Chat.send(chat(room, ws), ws, frame); + } catch (error) { + fail(ws, frame.rid, error instanceof Error ? error.message : "cannot send message"); + } return; case "chat:abort": @@ -356,6 +432,40 @@ async function receive(ws: Socket, raw: string): Promise { if (room.plan) Chat.unqueue(chat(room, ws), ws, frame); return; + case "conversation-plan:correct": + case "conversation-plan:scoped-choice-save": + case "conversation-plan:retry": + case "conversation-plan:retry-job": + await handleConversationCommand(frame, room, ws, { + enabled: !!config.conversationPlan, + runtime: conversationRuntime, + unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), + refreshAccess: () => refreshAccess(ws), + chat: () => chat(room, ws), + }); + return; + + case "conversation-plan:research": + case "conversation-plan:research-link": + await handleResearchCommand(frame, room, ws, { + enabled: !!config.conversationPlan, + runtime: conversationRuntime, + research: () => researchService, + unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), + refreshAccess, + start: (current, socket, opened, offer) => + startAcceptedResearch(current, socket, opened, offer, { + research: () => researchService, + auth: hostedAuth, + refreshAccess, + unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), + ownerAvailable: id => jobRunner?.ownerAvailable(id) ?? Promise.resolve(), + placeReference: placeResearchReference, + scheduleRecovery: scheduleResearchRecovery, + }), + }); + return; + case "question:open": if (room.plan) Questions.open(room.plan, ws, frame); return; @@ -377,7 +487,18 @@ async function receive(ws: Socket, raw: string): Promise { return; case "question:submit": - if (room.plan) await Questions.submit(room.plan, server, room.id, ws, frame); + if (room.plan) { + let opened = room.plan; + let claimant = chat(room, ws); + await Questions.submit( + opened, + server, + room.id, + ws, + frame, + intent => conversationRuntime.contexts.remember(opened, intent.trigger, claimant), + ); + } return; case "question:cancel": @@ -445,28 +566,17 @@ async function receive(ws: Socket, raw: string): Promise { } } -const VIEWER_ALLOWED = new Set(["session:ping", "plan:open", "plan:close", "job:list", "job:get"]); +const VIEWER_ALLOWED = new Set([ + "session:ping", + "plan:open", + "plan:close", + "job:list", + "job:get", + "conversation-plan:research-link", +]); async function refreshAccess(ws: Socket, forceGitHub = false): Promise { - let data = ws.data; - if (data.closed) return "denied"; - if (data.authorizationRefresh) { - let result = await data.authorizationRefresh; - if ( - result !== "allowed" - || !forceGitHub - || Date.now() - (data.accessCheckedAt ?? 0) < ACCESS_RECHECK_MS - ) { - return result; - } - } - let refresh = checkAccess(ws, forceGitHub); - data.authorizationRefresh = refresh; - try { - return await refresh; - } finally { - if (data.authorizationRefresh === refresh) data.authorizationRefresh = undefined; - } + return refreshAuthorization(ws.data, forceGitHub, forced => checkAccess(ws, forced)); } function applyChannelAccess( @@ -705,7 +815,13 @@ function drain(): Promise { if (result.status === "rejected") record(result.reason); } let rooms = await Promise.allSettled( - Rooms.all().map(room => room.plan && Service.close(room.plan)), + Rooms.all().map(async room => { + let opened = room.plan; + if (!opened) return; + let stopped = conversationRuntime.stop(opened); + await stopped; + await Service.close(opened); + }), ); for (let result of rooms) { if (result.status === "rejected") record(result.reason); @@ -835,7 +951,10 @@ async function archiveChannelLocked(channelId: string, now: Date) { let result = await withDocumentLock(channelId, async () => { let active = Rooms.get(channelId)?.plan; if (active) { + let stopped = conversationRuntime.stop(active); await Chat.resetAgent(active.chat, undefined, undefined, "This document was archived."); + await stopped; + await conversationRuntime.interrupt(active); await Service.drain(active); await Service.persist(active); } @@ -849,6 +968,17 @@ async function archiveChannelLocked(channelId: string, now: Date) { throw err; } finally { archivingChannels.delete(channelId); + let current = Rooms.get(channelId); + let opened = await recoverAttachedPlan(current); + if (current && opened) { + let channel = await storage.channels.get(channelId); + if ( + Rooms.get(channelId) === current && current.plan === opened + && !current.closing && !deletingChannels.has(channelId) + ) { + await conversationRuntime.attach(current, opened, !!channel?.archivedAt); + } + } } } @@ -870,6 +1000,8 @@ async function restoreChannelLocked(channelId: string, now: Date) { ); scheduleResearchRecovery(recovery?.deferred ?? 0); summaryCoordinator?.resume(channelId); + let current = Rooms.get(channelId); + if (current?.plan) await conversationRuntime.attach(current, current.plan, false); announceChannel(result.channel); if (summaryCoordinator) void summaryCoordinator.ensure(channelId).catch(() => {}); return result; @@ -897,7 +1029,11 @@ async function deleteChannelLocked(channelId: string): Promise { let activeRoom = Rooms.get(channelId); let active = activeRoom?.plan; if (activeRoom) activeRoom.plan = undefined; - if (active) await Service.close(active); + if (active) { + let stopped = conversationRuntime.stop(active); + await stopped; + await Service.close(active); + } return storage.channels.delete(channelId); }); if (!deleted) throw new StorageError("missing", `channel ${channelId} does not exist`); diff --git a/apps/server/src/questions/backfill-implementation.test.ts b/apps/server/src/questions/backfill-implementation.test.ts new file mode 100644 index 00000000..7776ed68 --- /dev/null +++ b/apps/server/src/questions/backfill-implementation.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan, storedQuestion } from "../testing/plan"; +import { backfillPlannerAskThreads } from "./backfill"; +import * as Questions from "./service"; + +test("legacy backfill refuses an implementation claim without publishing or persisting links", async () => { + let id = ulid(); + let definition = Questions.identify({ + questions: [{ + header: "Storage", + question: "Where should data live?", + multiple: false, + options: [{ label: "PostgreSQL", description: "Shared." }], + }], + }); + let question = definition.questions[0]!; + let document = await room.create(); + let source: string; + try { + room.insertQuestionnaire(document, { + id, + questions: [{ + id: question.id, + header: question.header, + prompt: question.question, + multiple: question.multiple, + options: question.options, + }], + }); + source = room.project(document); + } finally { + document.doc.destroy(); + } + let context = await openPlan(source, { + questions: [{ + id, + definition, + status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }], + openQuestions: [{ + id, + definition: Question.decision(definition), + widget: id, + model: storedQuestion(Question.decision(definition)), + revision: 0, + }], + }); + let { plan } = context; + try { + let revision = plan.persistence.revision; + plan.claiming = true; + await expect(backfillPlannerAskThreads(plan)).rejects.toThrow("implementation is active"); + expect(plan.persistence.revision).toBe(revision); + expect(plan.records.get(id)?.threadId).toBeUndefined(); + expect(plan.conversationPlan.events).toEqual([]); + expect(room.project(plan.document)).toBe(source); + expect(context.broadcasts).toEqual([]); + } finally { + plan.claiming = false; + await Service.close(plan); + } + let reopened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(reopened.records.get(id)?.threadId).toBeUndefined(); + expect(reopened.conversationPlan.events).toEqual([]); + expect(room.project(reopened.document)).toBe(source); + expect(await backfillPlannerAskThreads(reopened)).toBe(1); + } finally { + await Service.close(reopened); + } +}); diff --git a/apps/server/src/questions/backfill.test.ts b/apps/server/src/questions/backfill.test.ts new file mode 100644 index 00000000..eb049c5a --- /dev/null +++ b/apps/server/src/questions/backfill.test.ts @@ -0,0 +1,256 @@ +import { afterEach, expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; + +import { buildTargetingRequest } from "../conversation-plan/questions"; +import { extractQuotes } from "../conversation-plan/quotes"; +import { applyEvent } from "../conversation-plan/events"; +import { publishOpenedPlan } from "../conversation-plan/service-opening"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan, storedQuestion } from "../testing/plan"; +import { backfillPlannerAskThreads } from "./backfill"; +import * as Questions from "./service"; + +import type { Questionnaire } from "@chopin/dialect"; +import type { Plan } from "../plan/service"; + +let plans: Plan[] = []; +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + plans = []; +}); + +async function source(values: Questionnaire[]): Promise { + let document = await room.create(); + try { + room.insertQuestionnaires(document, values.map(value => ({ value }))); + return room.project(document); + } finally { + document.doc.destroy(); + } +} + +async function legacy(overrides: { + record?: Record; + projection?: Partial; + copies?: number; + optionOrigin?: "human" | "chat"; +} = {}) { + let id = ulid(); + let definition = Questions.identify({ + questions: [{ + header: "Storage", + question: "Where should room state live?", + multiple: false, + options: [ + { label: "MDX on disk", description: "Readable." }, + { label: "PostgreSQL", description: "Shared." }, + ], + }], + }); + let question = definition.questions[0]!; + let value: Questionnaire = { + id, + questions: [{ + id: question.id, + header: question.header, + prompt: question.question, + multiple: question.multiple, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + }], + ...overrides.projection, + }; + let record = { + id, + definition, + status: "open", + origin: "planner", + history: [], + optionOrigins: overrides.optionOrigin + ? { [question.options[0]!.id]: { origin: overrides.optionOrigin } } + : {}, + editors: [], + ...overrides.record, + }; + let status = record.status; + let context = await openPlan(await source(Array(overrides.copies ?? 1).fill(value)), { + questions: [record], + openQuestions: status === "open" || status === "reopened" + ? [{ + id, + definition: Question.decision(definition), + widget: id, + model: storedQuestion(Question.decision(definition)), + revision: 0, + }] + : [], + }); + plans.push(context.plan); + return { ...context, id, definition }; +} + +test("legacy Planner ask migrates once, survives reopen, and enters Jev targeting", async () => { + let context = await legacy(); + let { plan, id, definition } = context; + let before = { + jobs: plan.conversationPlanJobs.length, + effects: plan.conversationPlanEffects.length, + pending: plan.conversationPlanPendingEffects.length, + queue: plan.conversationPlan.queue.length, + }; + expect(await backfillPlannerAskThreads(plan)).toBe(1); + let record = plan.records.get(id)!; + let thread = plan.conversationPlan.threads[0]!; + expect(record.threadId).toBe(`planner-ask:${id}`); + expect(thread).toMatchObject({ + id: record.threadId, + questionnaireId: id, + questionSources: [], + questionAuthoring: "scribe", + }); + expect(thread.contributions.map(item => item.id)).toEqual( + definition.questions[0]!.options.map(option => option.id), + ); + expect(room.project(plan.document)).toContain(`thread="${record.threadId}"`); + expect(plan.conversationPlan.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "card.linked", + ]); + expect({ + jobs: plan.conversationPlanJobs.length, + effects: plan.conversationPlanEffects.length, + pending: plan.conversationPlanPendingEffects.length, + queue: plan.conversationPlan.queue.length, + }).toEqual(before); + let message = { + id: "quote", + author: { kind: "member" as const, handle: "ana" }, + text: "I support MDX on disk.", + ts: 1, + }; + let target = buildTargetingRequest( + message, + [], + plan.conversationPlan.threads, + extractQuotes(message.text), + ); + expect((target.questions.c0_thread as { criteria: Record }).criteria[thread.id]) + .toContain(definition.questions[0]!.question); + expect( + (target.questions.c0_option as { criteria: Record }) + .criteria[definition.questions[0]!.options[0]!.id], + ).toContain("MDX on disk"); + + await Service.close(plan); + plans = []; + let reopened = await Service.open(context.channel.id, context.backend, context.server); + plans.push(reopened); + expect(reopened.records.get(id)?.threadId).toBe(thread.id); + expect(reopened.conversationPlan).toEqual(plan.conversationPlan); + expect(room.project(reopened.document)).toEqual(room.project(plan.document)); + expect(await backfillPlannerAskThreads(reopened)).toBe(0); + expect(reopened.conversationPlan).toEqual(plan.conversationPlan); +}); + +test("first-open preparation finishes the backfill before room exposure", async () => { + let context = await legacy(); + let opening: { plan?: Plan } = {}; + let derivedNotifications = 0; + context.plan.persistence.onDocumentPersisted = () => derivedNotifications++; + await publishOpenedPlan(opening, context.plan, async () => { + expect(opening.plan).toBeUndefined(); + expect(await backfillPlannerAskThreads(context.plan)).toBe(1); + expect(context.plan.records.get(context.id)?.threadId).toBeDefined(); + expect(opening.plan).toBeUndefined(); + }); + expect(opening.plan).toBe(context.plan); + expect(derivedNotifications).toBe(0); +}); + +test("human and chat option provenance each prevent a metadata migration", async () => { + for (let optionOrigin of ["human", "chat"] as const) { + let context = await legacy({ optionOrigin }); + let source = room.project(context.plan.document); + let revision = context.plan.persistence.revision; + expect(await backfillPlannerAskThreads(context.plan)).toBe(0); + expect(context.plan.persistence.revision).toBe(revision); + expect(context.plan.records.get(context.id)?.threadId).toBeUndefined(); + expect(context.plan.conversationPlan.events).toEqual([]); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toEqual([]); + } +}); + +test("ambiguous or mismatched legacy projections are left alone", async () => { + for ( + let input of [ + { copies: 2 }, + { projection: { thread: "already-linked" } }, + { projection: { status: "reopened" as const } }, + { projection: { id: ulid() } }, + { record: { status: "answered" } }, + { record: { status: "reopened" } }, + ] + ) { + let context = await legacy(input); + let before = room.project(context.plan.document); + expect(await backfillPlannerAskThreads(context.plan)).toBe(0); + expect(context.plan.conversationPlan.events).toEqual([]); + expect(context.plan.records.get(context.id)?.threadId).toBeUndefined(); + expect(room.project(context.plan.document)).toBe(before); + } +}); + +test("a thread-ID collision or full conversation leaves the legacy card unchanged", async () => { + for (let full of [false, true]) { + let context = await legacy(); + let { plan, id } = context; + let source = room.project(plan.document); + let count = full ? 20 : 1; + for (let index = 0; index < count; index++) { + plan.conversationPlan = applyEvent(plan.conversationPlan, { + id: `existing-${index}`, + type: "thread.opened", + threadId: full ? `existing-thread-${index}` : `planner-ask:${id}`, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "An existing question?", + }); + } + expect(await backfillPlannerAskThreads(plan)).toBe(0); + expect(plan.records.get(id)?.threadId).toBeUndefined(); + expect(room.project(plan.document)).toBe(source); + expect(plan.conversationPlan.threads).toHaveLength(count); + } +}); + +test("failed migration commit leaves live and durable card unlinked", async () => { + let context = await legacy(); + let { plan, id } = context; + let before = room.project(plan.document); + let original = context.storage.collaboration.commit.bind(context.storage.collaboration); + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + await expect(backfillPlannerAskThreads(plan)).rejects.toThrow("storage unavailable"); + expect(plan.records.get(id)?.threadId).toBeUndefined(); + expect(plan.conversationPlan.events).toEqual([]); + expect(room.project(plan.document)).toBe(before); + (context.storage.collaboration as { commit: typeof original }).commit = original; + await Service.close(plan); + plans = []; + let reopened = await Service.open(context.channel.id, context.backend, context.server); + plans.push(reopened); + expect(reopened.records.get(id)?.threadId).toBeUndefined(); + expect(reopened.conversationPlan.events).toEqual([]); + expect(room.project(reopened.document)).toBe(before); +}); diff --git a/apps/server/src/questions/backfill.ts b/apps/server/src/questions/backfill.ts new file mode 100644 index 00000000..3a2fae0c --- /dev/null +++ b/apps/server/src/questions/backfill.ts @@ -0,0 +1,142 @@ +/** Link legacy open Planner questions to conversation threads before room exposure. */ + +import { ULID } from "@chopin/dialect"; +import * as Y from "yjs"; + +import { applyEvent, assertEventCapacity } from "../conversation-plan/events"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; + +import type { Questionnaire } from "@chopin/dialect"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { Record } from "./records"; + +function matches(record: Record, projections: Questionnaire[]): boolean { + if ( + record.origin !== "planner" || record.status !== "open" || record.history.length !== 0 + || record.threadId || record.definition.questions.length !== 1 + || Object.values(record.optionOrigins).some(origin => origin.origin !== "planner") + ) return false; + let matching = projections.filter(value => value.id === record.id); + if (matching.length !== 1) return false; + let value = matching[0]!; + let question = record.definition.questions[0]!; + let projected = value.questions[0]; + return !value.thread && (value.status === undefined || value.status === "open") + && !value.by && !value.at && value.questions.length === 1 && !!projected + && projected.answer === undefined && projected.choices === undefined + && projected.previous === undefined + && projected.id === question.id && projected.header === question.header + && projected.prompt === question.question && projected.multiple === question.multiple + && projected.options.length === question.options.length + && projected.options.every((option, index) => { + let expected = question.options[index]!; + return option.id === expected.id && option.label === expected.label + && (option.description ?? "") === (expected.description ?? ""); + }); +} + +function eventsFor( + state: ConversationPlan.State, + record: Record, + threadId: string, +): ConversationPlan.State { + let at = 0; + let question = record.definition.questions[0]!; + let next = applyEvent(state, { + id: `backfill:${record.id}:opened`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at, + question: question.question, + }); + for (let option of question.options) { + let version = next.threads.find(thread => thread.id === threadId)!.version; + next = applyEvent(next, { + id: `backfill:${record.id}:option:${option.id}`, + type: "option.added", + threadId, + observedThreadVersion: version, + origin: "planner", + actor: { kind: "agent" }, + at, + contribution: { id: option.id, text: option.label, authoring: "scribe", targetId: threadId }, + }); + } + return applyEvent(next, { + id: `backfill:${record.id}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: next.threads.find(thread => thread.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at, + questionnaireId: record.id, + }); +} + +/** Safe to retry: every surviving link is fixed by the card's persisted identity. */ +export function backfillPlannerAskThreads(plan: Plan): Promise { + return Service.exclusive(plan, async () => { + let projections = room.questionnaireProjections(plan.document); + let records = new Map(plan.records); + let conversationPlan = plan.conversationPlan; + let links: Array<{ id: string; threadId: string }> = []; + for (let record of [...plan.records.values()].toSorted((a, b) => a.id.localeCompare(b.id))) { + if (!matches(record, projections) || !ULID.test(record.id)) continue; + let threadId = `planner-ask:${record.id}`; + if ( + [...plan.records.values()].some(other => other.threadId === threadId) + || projections.some(value => value.thread === threadId) + ) continue; + try { + let next = eventsFor(conversationPlan, record, threadId); + assertEventCapacity(next, plan.pendingCardActions.length); + if ( + next === conversationPlan + || !next.threads.some(thread => + thread.id === threadId && thread.questionnaireId === record.id + ) + ) continue; + conversationPlan = next; + records.set(record.id, { ...record, threadId }); + links.push({ id: record.id, threadId }); + } catch { + // A collision or exhausted conversation limit must not block another safe card. + } + } + if (links.length === 0) return 0; + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let mutation = room.linkQuestionnaireThreads(stagedDocument, links); + if (!mutation) throw new Error("Planner card backfill produced no document update"); + // A thread attribute changes MDX without adding prose for the summary model. + await Service.publishStaged( + plan, + plan.server, + plan.id, + { + ...plan, + document: stagedDocument, + records, + conversationPlan, + }, + mutation, + { notifyDocumentPersisted: false }, + ); + return links.length; + } finally { + stagedDocument.doc.destroy(); + } + }); +} diff --git a/apps/server/src/socket/authorization.test.ts b/apps/server/src/socket/authorization.test.ts new file mode 100644 index 00000000..43d691c9 --- /dev/null +++ b/apps/server/src/socket/authorization.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { refreshAuthorization } from "./authorization"; + +import type { AuthorizationResult } from "../wire"; + +test("ordinary callers share a pending authorization check", async () => { + let gate = Promise.withResolvers(); + let calls: boolean[] = []; + let state = {}; + let check = (forced: boolean) => { + calls.push(forced); + return gate.promise; + }; + let first = refreshAuthorization(state, false, check); + let second = refreshAuthorization(state, false, check); + expect(calls).toEqual([false]); + gate.resolve("allowed"); + expect(await first).toBe("allowed"); + expect(await second).toBe("allowed"); + expect(calls).toEqual([false]); +}); + +test("a forced read rechecks GitHub after a cached ordinary check was already in flight", async () => { + let gate = Promise.withResolvers(); + let calls: boolean[] = []; + let hasPull = true; + let state = {}; + let check = (forced: boolean) => { + calls.push(forced); + return forced + ? Promise.resolve(hasPull ? "allowed" : "denied") + : gate.promise; + }; + let ordinary = refreshAuthorization(state, false, check); + let forced = refreshAuthorization(state, true, check); + expect(calls).toEqual([false]); + hasPull = false; + gate.resolve("allowed"); + expect(await ordinary).toBe("allowed"); + expect(await forced).toBe("denied"); + expect(calls).toEqual([false, true]); +}); + +test("forced callers retain denied and unavailable in-flight results", async () => { + for (let outcome of ["denied", "unavailable"] as const) { + let gate = Promise.withResolvers(); + let calls: boolean[] = []; + let state = {}; + let check = (forced: boolean) => { + calls.push(forced); + return gate.promise; + }; + let ordinary = refreshAuthorization(state, false, check); + let forced = refreshAuthorization(state, true, check); + gate.resolve(outcome); + expect(await ordinary).toBe(outcome); + expect(await forced).toBe(outcome); + expect(calls).toEqual([false]); + } +}); diff --git a/apps/server/src/socket/authorization.ts b/apps/server/src/socket/authorization.ts new file mode 100644 index 00000000..9af3dd15 --- /dev/null +++ b/apps/server/src/socket/authorization.ts @@ -0,0 +1,26 @@ +import type { AuthorizationResult } from "../wire"; + +type AuthorizationRefreshState = { + closed?: boolean; + authorizationRefresh?: Promise; +}; + +/** Share ordinary checks, but keep a forced repository check fresh after an in-flight result. */ +export async function refreshAuthorization( + state: AuthorizationRefreshState, + forceGitHub: boolean, + check: (forceGitHub: boolean) => Promise, +): Promise { + if (state.closed) return "denied"; + if (state.authorizationRefresh) { + let result = await state.authorizationRefresh; + if (result !== "allowed" || !forceGitHub) return result; + } + let refresh = check(forceGitHub); + state.authorizationRefresh = refresh; + try { + return await refresh; + } finally { + if (state.authorizationRefresh === refresh) state.authorizationRefresh = undefined; + } +} diff --git a/apps/server/src/testing/card-gap-document.ts b/apps/server/src/testing/card-gap-document.ts new file mode 100644 index 00000000..447c10fc --- /dev/null +++ b/apps/server/src/testing/card-gap-document.ts @@ -0,0 +1,25 @@ +import { $createParagraphNode, $getRoot } from "lexical"; +import * as Y from "yjs"; +import { QuestionnaireNode } from "@chopin/dialect"; + +import * as Room from "../plan/room"; + +/** Persist the legacy shared caret run seen between adjacent decision cards. */ +export async function storedCardGapDocument(source: string) { + let document = await Room.create(source); + try { + document.editor.update(() => { + let second = $getRoot().getChildren().filter(node => node instanceof QuestionnaireNode)[1]; + if (!second) throw new Error("two questionnaire nodes are required"); + for (let index = 0; index < 3; index++) second.insertBefore($createParagraphNode()); + }, { discrete: true }); + await Room.settle(); + return { + epoch: document.epoch, + source: Room.project(document), + update: Y.encodeStateAsUpdate(document.doc), + }; + } finally { + document.doc.destroy(); + } +} diff --git a/apps/web/package.json b/apps/web/package.json index a7f79007..eb654f3c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@chopin/editor": "workspace:*", "@chopin/icons": "workspace:*", "@chopin/protocol": "workspace:*", + "@chopin/question": "workspace:*", "@chopin/visuals": "workspace:*", "@chopin/viewport": "workspace:*", "@fontsource-variable/inter": "^5.3.0", diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 17128dd0..2e4af586 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -45,10 +45,15 @@ import { reviseComposerDraft, } from "./references"; import { Transcript } from "./transcript"; +import type { TranscriptDecisions } from "./transcript"; +import type { CardLink } from "../conversation-plan/links"; +import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-overview"; +import type { ChatDestination } from "../conversation-plan/source"; +import type { ResearchOfferControls } from "./research-offer"; import { TerminalAlert } from "../terminal-alert"; import plannerStop from "../assets/icons/planner-stop.svg"; -import type { Chat as Wire } from "@chopin/protocol"; +import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; import type { MentionCandidate } from "./mentions"; import type { ComposerDraft, ReferenceTarget } from "./references"; @@ -68,6 +73,15 @@ export type ChatProps = { agent?: boolean; active?: boolean; onActivity?: (event: { type: "message" | "working"; busy: boolean }) => void; + conversationPlan?: ConversationPlan.State; + conversationPlanJobs?: ConversationPlan.Job[]; + onCardLink?: (link: CardLink) => void; + onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; + onRetryAnalysis?: (messageId: string, actionId: string) => Promise; + onRetryJob?: (jobId: string) => Promise; + decisions?: TranscriptDecisions; + researchOffers?: ResearchOfferControls; + sourceDestination?: ChatDestination; }; export function Chat( @@ -77,6 +91,15 @@ export function Chat( connected, handle, onActivity, + conversationPlan, + conversationPlanJobs, + onCardLink, + onAddExcerpt, + onRetryAnalysis, + onRetryJob, + decisions, + researchOffers, + sourceDestination, people = [], referencesEnabled, repository, @@ -337,6 +360,16 @@ export function Chat(

wire?.send("chat:unqueue", { id })} diff --git a/apps/web/src/chat/decision-entry.test.tsx b/apps/web/src/chat/decision-entry.test.tsx new file mode 100644 index 00000000..e09c82e7 --- /dev/null +++ b/apps/web/src/chat/decision-entry.test.tsx @@ -0,0 +1,267 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ActivityLine, DecisionPrompt, promptSelection, promptView } from "./decision-entry"; + +import type { Questionnaire } from "@chopin/dialect"; +import type { Definition, Drafts } from "@chopin/question"; +import type { Chat, Question } from "@chopin/protocol"; + +const VALUE: Questionnaire = { + id: "Q", + by: "mina", + questions: [{ + id: "auth", + header: "Auth", + prompt: "What auth system should we use?", + multiple: false, + options: [ + { id: "a", label: "Auth0" }, + { id: "b", label: "GitHub Apps" }, + ], + }], +}; + +const DEFINITION: Definition = { + questions: [{ + id: "auth", + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: [ + { id: "a", label: "Auth0", description: "" }, + { id: "b", label: "GitHub Apps", description: "" }, + ], + }], +}; + +const EMPTY_DRAFTS: Drafts = { + auth: { mode: "choices", choice: null, options: { a: false, b: false }, custom: "" }, +}; + +function openMeta(overrides: Partial = {}): Question.CardMeta { + return { + status: "open", + origin: "conversation", + involved: [], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, + ...overrides, + }; +} + +function promptEntry(generation: number, ts = 10): Chat.Entry & { + author: { kind: "system" }; + decision: { questionnaireId: string; kind: "prompt"; generation: number }; +} { + return { + id: `prompt-${generation}`, + author: { kind: "system" }, + text: "Ready to decide: What auth system should we use?", + ts, + decision: { questionnaireId: "Q", kind: "prompt", generation }, + }; +} + +test("the current prompt generation stays live across a same-second reopen", () => { + let savedValue = { + ...VALUE, + status: "decided" as const, + questions: [{ ...VALUE.questions[0]!, answer: "GitHub Apps", choices: ["b"] }], + }; + let saved = openMeta({ + status: "decided", + history: [{ choices: ["b"], owner: "mina", at: 10 }], + }); + let reopened = { ...saved, status: "reopened" as const }; + + expect(promptView({ entry: promptEntry(0), latest: true, meta: saved, value: savedValue })) + .toEqual({ state: "collapsed", text: "Decided: GitHub Apps · mina" }); + expect(promptView({ entry: promptEntry(0), latest: true, meta: reopened, value: savedValue })) + .toEqual({ state: "collapsed", text: "Reopened" }); + expect(promptView({ entry: promptEntry(0), latest: false, meta: reopened, value: savedValue })) + .toEqual({ state: "collapsed", text: "Reopened" }); + expect(promptView({ entry: promptEntry(1), latest: false, meta: reopened, value: savedValue })) + .toEqual({ state: "collapsed", text: "Superseded by a later prompt" }); + expect(promptView({ entry: promptEntry(1), latest: true, meta: reopened, value: savedValue })) + .toEqual({ state: "live" }); +}); + +test("old, missing, and superseded prompt state cannot stay live", () => { + let meta = openMeta({ history: [{ choices: ["b"], owner: "mina", at: 10 }] }); + + expect(promptView({ entry: promptEntry(0), latest: true, meta, value: VALUE })) + .toEqual({ state: "collapsed", text: "Decision changed" }); + expect(promptView({ entry: promptEntry(1), latest: false, meta, value: VALUE })) + .toEqual({ state: "collapsed", text: "Superseded by a later prompt" }); + expect(promptView({ entry: promptEntry(0), latest: true, value: VALUE })) + .toEqual({ state: "collapsed", text: "Decision unavailable" }); + expect(promptView({ entry: promptEntry(0), latest: true, meta })) + .toEqual({ state: "collapsed", text: "Decision unavailable" }); + expect(promptView({ entry: promptEntry(1), latest: true, meta: openMeta(), value: VALUE })) + .toEqual({ state: "collapsed", text: "Decision changed" }); +}); + +test("a decision without labels uses its saved-answer fallback and owner", () => { + let custom = { + ...VALUE, + questions: [{ ...VALUE.questions[0]!, answer: "Use the hosted provider", choices: undefined }], + }; + let meta = openMeta({ status: "decided", owner: "jules" }); + expect(promptView({ entry: promptEntry(0), latest: true, meta, value: custom })) + .toEqual({ state: "collapsed", text: "Decided: Use the hosted provider · jules" }); + let unanswered = { ...custom, questions: [{ ...custom.questions[0]!, answer: undefined }] }; + expect(promptView({ entry: promptEntry(0), latest: true, meta, value: unanswered })) + .toEqual({ state: "collapsed", text: "Decided: Saved decision · jules" }); +}); + +test("prompt selection shares the card projection and exact suggestion snapshot", () => { + let suggested = { optionId: "b", revision: 7 }; + let projection = promptSelection(DEFINITION, EMPTY_DRAFTS, suggested); + expect(projection).toEqual({ + optionId: "b", + label: "GitHub Apps", + visibleSuggestion: suggested, + }); + expect(projection.visibleSuggestion).toBe(suggested); + + let human = promptSelection(DEFINITION, { + auth: { ...EMPTY_DRAFTS.auth!, choice: "a" }, + }, suggested); + expect(human).toEqual({ optionId: "a", label: "Auth0" }); + + let moved = promptSelection( + { + questions: [{ + ...DEFINITION.questions[0]!, + options: [DEFINITION.questions[0]!.options[0]!], + }], + }, + EMPTY_DRAFTS, + suggested, + ); + expect(moved).toEqual({}); +}); + +test("prompt selection does not change multi-question or multi-select cards", () => { + let multipleChoice: Definition = { + questions: [{ ...DEFINITION.questions[0]!, multiple: true }], + }; + let multipleQuestions: Definition = { + questions: [DEFINITION.questions[0]!, { ...DEFINITION.questions[0]!, id: "scope" }], + }; + let suggestion = { optionId: "b", revision: 7 }; + expect(promptSelection(multipleChoice, EMPTY_DRAFTS, suggestion)).toEqual({}); + expect(promptSelection(multipleQuestions, EMPTY_DRAFTS, suggestion)).toEqual({}); +}); + +test("a live prompt shows projected copy without avatars and disables Save when disconnected", () => { + let markup = renderToStaticMarkup(createElement(DecisionPrompt, { + entry: promptEntry(0), + latest: true, + value: VALUE, + meta: openMeta({ suggested: { optionId: "b", messageIds: ["m2"], revision: 7 } }), + connected: false, + canEdit: true, + onOpenCard() {}, + })); + + expect(markup).toContain("Suggested: GitHub Apps"); + expect(markup).toContain("Save decision"); + expect(markup).toContain('aria-label="Open in plan"'); + expect(markup).toMatch(/aria-label="Decision prompt: What auth system should we use\?"/); + expect(markup).toMatch(/]*disabled=""[^>]*>Save decision<\/button>/); + expect(markup).not.toContain(" { + let markup = renderToStaticMarkup(createElement(DecisionPrompt, { + entry: promptEntry(0), + latest: true, + value: VALUE, + meta: openMeta(), + connected: false, + canEdit: false, + onOpenCard() {}, + })); + + expect(markup).toContain("Choose an option on the card"); + expect(markup).toMatch(/]*disabled=""[^>]*>Save decision<\/button>/); +}); + +test("activity labels remain text for the document sentinel and link card activities", () => { + let documentActivity = renderToStaticMarkup(createElement(ActivityLine, { + entry: { + id: "heading-activity", + author: { kind: "system" }, + text: "Chopin refined the document heading", + ts: 10, + decision: { questionnaireId: "document", kind: "activity", label: "the document heading" }, + }, + latest: true, + connected: true, + canEdit: true, + onOpenCard() {}, + })); + expect(documentActivity).toContain("the document heading"); + expect(documentActivity).not.toMatch(/]*>the document heading<\/button>/); + + let cardActivity = renderToStaticMarkup(createElement(ActivityLine, { + entry: { + id: "card-activity", + author: { kind: "system" }, + text: "Chopin refined What auth system should we use?", + ts: 10, + decision: { + questionnaireId: "Q", + kind: "activity", + label: "What auth system should we use?", + }, + }, + latest: true, + connected: true, + canEdit: true, + onOpenCard() {}, + })); + expect(cardActivity).toMatch(/]*>What auth system should we use\?<\/button>/); +}); + +test("activity text stays exact when its label is interior or absent", () => { + let interior = renderToStaticMarkup(createElement(ActivityLine, { + entry: { + id: "card-activity", + author: { kind: "system" }, + text: "Chopin linked the architecture note to the auth card", + ts: 10, + decision: { questionnaireId: "Q", kind: "activity", label: "architecture note" }, + }, + latest: true, + connected: true, + canEdit: true, + onOpenCard() {}, + })); + expect(interior).toMatch( + /Chopin linked the ]*>architecture note<\/button> to the auth card/, + ); + expect(interior.match(/architecture note/g)).toHaveLength(1); + + let absent = renderToStaticMarkup(createElement(ActivityLine, { + entry: { + id: "heading-activity", + author: { kind: "system" }, + text: "Chopin drafted the title and goal", + ts: 10, + decision: { questionnaireId: "document", kind: "activity", label: "the document" }, + }, + latest: true, + connected: true, + canEdit: true, + onOpenCard() {}, + })); + expect(absent).toContain("Chopin drafted the title and goal"); + expect(absent).not.toContain("the document"); +}); diff --git a/apps/web/src/chat/decision-entry.tsx b/apps/web/src/chat/decision-entry.tsx new file mode 100644 index 00000000..1a6597bf --- /dev/null +++ b/apps/web/src/chat/decision-entry.tsx @@ -0,0 +1,222 @@ +/** System entries that show or link to a live decision card. */ + +import { DecisionIcon, DocumentIcon, SparkleIcon } from "@chopin/icons"; +import { projectSuggestion, useQuestionnaire } from "@chopin/question/react"; + +import type { Questionnaire } from "@chopin/dialect"; +import type { Definition, Drafts } from "@chopin/question"; +import type { Chat, Question } from "@chopin/protocol"; +import type { Transport, VisibleSuggestion } from "@chopin/question/react"; + +export type DecisionEntryProps = { + entry: Chat.Entry & { decision: NonNullable }; + latest: boolean; + value?: Questionnaire; + meta?: Question.CardMeta; + wire?: Transport; + connected: boolean; + canEdit: boolean; + onOpenCard: (questionnaireId: string) => void; +}; + +type PromptEntry = Chat.Entry & { + author: { kind: "system" }; + decision: Extract, { kind: "prompt" }>; +}; + +/** The document schema and questionnaire controller use slightly different field names. */ +function definition(value: Questionnaire | undefined): Definition | undefined { + if (!value) return undefined; + return { + questions: value.questions.map(question => ({ + id: question.id, + header: question.header, + question: question.prompt, + multiple: question.multiple, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + description: option.description ?? "", + })), + })), + }; +} + +export function promptSelection( + definition: Definition | undefined, + drafts: Drafts, + suggested: VisibleSuggestion | undefined, +): { optionId?: string; label?: string; visibleSuggestion?: VisibleSuggestion } { + if (definition?.questions.length !== 1) return {}; + let question = definition.questions[0]!; + if (question.multiple) return {}; + + let projection = projectSuggestion(question, drafts[question.id], suggested); + let optionId = projection.draft?.mode === "choices" ? projection.draft.choice : null; + let option = optionId ? question.options.find(item => item.id === optionId) : undefined; + if (!option) return {}; + return { + optionId: option.id, + label: option.label, + ...(projection.suggestion ? { visibleSuggestion: projection.suggestion } : {}), + }; +} + +function decidedText(value: Questionnaire | undefined, meta: Question.CardMeta): string { + let question = value?.questions[0]; + let labels = question?.choices?.flatMap(id => { + let option = question.options.find(candidate => candidate.id === id); + return option ? [option.label] : []; + }) ?? []; + if (labels.length === 0 && question?.answer) labels = [question.answer]; + let owner = meta.owner ?? value?.by; + return `Decided: ${labels.join(", ") || "Saved decision"}${owner ? ` · ${owner}` : ""}`; +} + +export function promptView( + { entry, latest, meta, value }: { + entry: PromptEntry; + latest: boolean; + meta?: Question.CardMeta; + value?: Questionnaire; + }, +): { state: "live" } | { state: "collapsed"; text: string } { + if (meta?.status === "discarded") return { state: "collapsed", text: "Discarded" }; + if (meta?.status === "decided") { + return { state: "collapsed", text: decidedText(value, meta) }; + } + if ( + meta?.status === "reopened" && value + && entry.decision.generation !== meta.history.length + ) return { state: "collapsed", text: "Reopened" }; + if (!latest) return { state: "collapsed", text: "Superseded by a later prompt" }; + if (!meta || !value) return { state: "collapsed", text: "Decision unavailable" }; + if (meta.status !== "open" && meta.status !== "reopened") { + return { state: "collapsed", text: "Decision unavailable" }; + } + if (entry.decision.generation !== meta.history.length) { + return { + state: "collapsed", + text: meta.status === "reopened" ? "Reopened" : "Decision changed", + }; + } + return { state: "live" }; +} + +function OpenInPlan({ id, onOpenCard }: { id: string; onOpenCard: (id: string) => void }) { + return ( + + ); +} + +export function DecisionPrompt(props: DecisionEntryProps) { + let { entry, latest, meta, value, wire, connected, canEdit, onOpenCard } = props; + let id = entry.decision.questionnaireId; + let view = promptView({ entry: entry as PromptEntry, latest, meta, value }); + let definitionValue = definition(value); + let title = value?.questions[0]?.prompt ?? entry.text.replace(/^Ready to decide:\s*/, ""); + let live = view.state === "live"; + let state = useQuestionnaire({ + id, + bridge: live ? wire : undefined, + connected: live && connected && canEdit, + definition: definitionValue, + }); + + if (view.state === "collapsed") { + return ( +
+
+ ); + } + + let currentDefinition = state.definition ?? definitionValue; + let selection = promptSelection(currentDefinition, state.drafts, meta?.suggested); + let enabled = live && !!wire && !!meta && !!value && canEdit && connected + && !state.syncing && !state.submitting && !!selection.optionId; + + return ( +
+
+ + +

{title}

+ +
+

+ {selection.label + ? `${selection.visibleSuggestion ? "Suggested" : "Selected"}: ${selection.label}` + : "Choose an option on the card"} +

+ {state.error &&

{state.error} +

} +
+ +
+
+ ); +} + +export function ActivityLine({ entry, onOpenCard }: DecisionEntryProps) { + let { questionnaireId, label } = entry.decision; + let labelStart = label ? entry.text.indexOf(label) : -1; + let matchedLabel = labelStart >= 0 ? label : undefined; + let content = matchedLabel === undefined + ? entry.text + : ( + <> + {entry.text.slice(0, labelStart)} + {questionnaireId === "document" + ? matchedLabel + : ( + + )} + {entry.text.slice(labelStart + matchedLabel.length)} + + ); + return ( +
+
+ ); +} diff --git a/apps/web/src/chat/markdown.css b/apps/web/src/chat/markdown.css index f6e8bb91..e41a3514 100644 --- a/apps/web/src/chat/markdown.css +++ b/apps/web/src/chat/markdown.css @@ -80,3 +80,7 @@ font-family: var(--font-mono); font-size: var(--text-sm); } + +.chat-message-body:has([data-analysis-trigger]) { + padding-inline-end: 2rem; +} diff --git a/apps/web/src/chat/model.test.ts b/apps/web/src/chat/model.test.ts index 5482546b..7f3224b4 100644 --- a/apps/web/src/chat/model.test.ts +++ b/apps/web/src/chat/model.test.ts @@ -148,3 +148,18 @@ describe("tool-run summaries", () => { ])).toEqual({ state: "finished", count: 2, failures: 1, elapsed: 1_238 }); }); }); + +it("preserves decision metadata and timestamp on system groups", () => { + let prompt = { + ...entry("decision-prompt", { kind: "system" }, "Ready to decide"), + decision: { questionnaireId: "card-1", kind: "prompt" as const, generation: 2 }, + }; + + expect(group([prompt], [])).toEqual([{ + kind: "system", + id: "decision-prompt", + text: "Ready to decide", + ts: 1_700_000_000, + decision: prompt.decision, + }]); +}); diff --git a/apps/web/src/chat/model.ts b/apps/web/src/chat/model.ts index 6521e598..ce9bdb7a 100644 --- a/apps/web/src/chat/model.ts +++ b/apps/web/src/chat/model.ts @@ -18,7 +18,7 @@ export type Message = { export type Group = | { kind: "messages"; author: Speaker; messages: Message[]; queued: boolean } - | { kind: "system"; id: string; text: string }; + | { kind: "system"; id: string; text: string; ts?: number; decision?: Chat.Entry["decision"] }; export type ToolSummary = | { state: "running"; name: string; completed: number } @@ -79,7 +79,13 @@ export function group( continue; } if (row.author.kind === "system") { - result.push({ kind: "system", id: row.id, text: row.text }); + result.push({ + kind: "system", + id: row.id, + text: row.text, + ts: row.ts, + decision: row.decision, + }); continue; } append(result, { ...row, author: row.author, queued: false }); diff --git a/apps/web/src/chat/research-offer-observer.test.ts b/apps/web/src/chat/research-offer-observer.test.ts new file mode 100644 index 00000000..6a7f40e1 --- /dev/null +++ b/apps/web/src/chat/research-offer-observer.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { fixture, pending, settle } from "./research-offer.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, apps/web/src/chat/research-offer.test.ts. +describe("accepted research offer link observer", () => { + test("one immediate read and only three delayed reads at 2/5/10 seconds", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + expect(f.reads).toEqual(["offer-1"]); + for (let ms of [2_000, 5_000, 10_000]) { + f.replies.at(-1)!.resolve(pending()); + await settle(); + expect([...f.timers.values()].map(item => item.ms)).toEqual([ms]); + f.tick(ms); + } + f.replies.at(-1)!.resolve(pending()); + await settle(); + expect(f.reads).toHaveLength(4); + expect(f.timers.size).toBe(0); + expect(f.links["offer-1"]).toEqual({ status: "pending", exhausted: true }); + f.observer.dispose(); + }); + + test("a change during an unresolved read forces one follow-up", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + f.observer.changed(); + f.observer.changed(); + expect(f.reads).toHaveLength(1); + f.replies[0]!.resolve(pending()); + await settle(); + expect(f.reads).toHaveLength(2); + f.replies[1]!.resolve({ + kind: "conversation-plan:research-link", + offerId: "offer-1", + status: "linked", + researchRequestId: "request-1", + ts: 1, + }); + await settle(); + expect(f.links["offer-1"]).toEqual({ status: "linked", researchRequestId: "request-1" }); + expect(f.timers.size).toBe(0); + f.observer.changed(); + expect(f.reads).toHaveLength(2); + }); + + test("a later change can read again after the fallback budget ends", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + for (let ms of [2_000, 5_000, 10_000]) { + f.replies.at(-1)!.resolve(pending()); + await settle(); + f.tick(ms); + } + f.replies.at(-1)!.resolve(pending()); + await settle(); + expect(f.links["offer-1"]?.exhausted).toBe(true); + f.observer.changed(); + expect(f.reads).toHaveLength(5); + f.replies.at(-1)!.resolve(pending()); + await settle(); + expect(f.timers.size).toBe(0); + f.observer.dispose(); + }); + + test("a reader can retry an exhausted link check without restarting research", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + for (let ms of [2_000, 5_000, 10_000]) { + f.replies.at(-1)!.reject(new Error("temporary read failure")); + await settle(); + f.tick(ms); + } + f.replies.at(-1)!.reject(new Error("temporary read failure")); + await settle(); + expect(f.links["offer-1"]).toEqual({ status: "error", exhausted: true }); + expect(f.timers.size).toBe(0); + + f.observer.refresh("offer-1", true); + expect(f.reads).toHaveLength(5); + expect(f.links["offer-1"]).toEqual({ status: "checking" }); + f.replies.at(-1)!.resolve({ + kind: "conversation-plan:research-link", + offerId: "offer-1", + status: "linked", + researchRequestId: "request-1", + ts: 1, + }); + await settle(); + expect(f.links["offer-1"]).toEqual({ status: "linked", researchRequestId: "request-1" }); + f.observer.dispose(); + }); + + test("removed offers and disposal ignore stale replies and cancel timers", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + f.observer.accept(new Set()); + f.replies[0]!.resolve(pending()); + await settle(); + expect(f.links).toEqual({}); + expect(f.timers.size).toBe(0); + f.observer.accept(new Set(["offer-1"])); + f.replies[1]!.resolve(pending()); + await settle(); + expect(f.timers.size).toBe(1); + f.observer.dispose(); + expect(f.timers.size).toBe(0); + expect(f.cancelled).toHaveLength(1); + f.observer.changed(); + expect(f.reads).toHaveLength(2); + }); + + test("mismatched or malformed link replies never become linked", async () => { + let f = fixture(); + f.observer.accept(new Set(["offer-1"])); + f.replies[0]!.resolve({ + kind: "conversation-plan:research-link", + offerId: "other-offer", + status: "linked", + researchRequestId: "request-1", + ts: 1, + }); + await settle(); + expect(f.links["offer-1"]?.status).toBe("error"); + f.observer.dispose(); + }); +}); diff --git a/apps/web/src/chat/research-offer-transcript.test.ts b/apps/web/src/chat/research-offer-transcript.test.ts new file mode 100644 index 00000000..2176f0e4 --- /dev/null +++ b/apps/web/src/chat/research-offer-transcript.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Transcript } from "./transcript"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { ResearchRequestStore } from "../research-requests"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, apps/web/src/chat/research-offer.test.ts. +// Whole original source-placement and viewer-authority callback. +test("the exact public brief appears under its source message and viewer controls stay absent", () => { + let brief = "Research VPS costs assuming a provider."; + let state: ConversationPlan.State = { + schemaVersion: 1, + revision: 1, + events: [], + threads: [], + queue: [], + analysis: [], + researchOffers: [{ + id: "offer-1", + needId: "need-1", + contextId: "context-1", + brief, + status: "offered", + source: { + messageId: "source-2", + author: { kind: "member", handle: "ana" }, + quote: "Compare VPS costs", + start: 0, + end: 17, + }, + }], + }; + let render = (canAct: boolean) => + renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [ + { id: "source-1", author: { kind: "member", handle: "ana" }, text: "Earlier", ts: 1 }, + { + id: "source-2", + author: { kind: "member", handle: "ana" }, + text: "Compare VPS costs", + ts: 2, + }, + ], + handle: "ana", + onWithdraw: () => {}, + queued: [], + conversationPlan: state, + researchOffers: { + links: {}, + busy: new Set(), + errors: {}, + canAct, + canCheckLink: true, + store: {} as ResearchRequestStore, + onAction: () => {}, + onRetryLink: () => {}, + }, + })); + let writer = render(true); + let source = writer.indexOf('data-chat-message-id="source-2"'); + let offer = writer.indexOf('data-research-offer="offer-1"'); + expect(source).toBeGreaterThan(0); + expect(offer).toBeGreaterThan(source); + expect(writer).toContain("Research VPS costs <without> assuming a provider."); + expect(writer).toContain(">Research"); + expect(writer).toContain(">Dismiss"); + let viewer = render(false); + expect(viewer).toContain('data-research-offer="offer-1"'); + expect(viewer).not.toContain(">Research"); + expect(viewer).not.toContain(">Dismiss"); +}); diff --git a/apps/web/src/chat/research-offer.test-fixtures.ts b/apps/web/src/chat/research-offer.test-fixtures.ts new file mode 100644 index 00000000..927b460f --- /dev/null +++ b/apps/web/src/chat/research-offer.test-fixtures.ts @@ -0,0 +1,68 @@ +import { expect } from "bun:test"; +import { ResearchOfferLinkObserver } from "./research-offer"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { OfferLinkView } from "./research-offer"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, apps/web/src/chat/research-offer.test.ts. +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + let promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +export function fixture() { + let reads: string[] = []; + let replies: ReturnType>[] = []; + let timers = new Map void; ms: number }>(); + let cancelled: number[] = []; + let nextTimer = 0; + let links: Readonly> = {}; + let observer = new ResearchOfferLinkObserver( + id => { + reads.push(id); + let reply = deferred(); + replies.push(reply); + return reply.promise; + }, + value => links = value, + (run, ms) => { + let id = ++nextTimer; + timers.set(id, { run, ms }); + return id as unknown as ReturnType; + }, + timer => { + let id = timer as unknown as number; + cancelled.push(id); + timers.delete(id); + }, + ); + let tick = (ms: number) => { + let entry = [...timers].find(([, value]) => value.ms === ms); + expect(entry).toBeDefined(); + timers.delete(entry![0]); + entry![1].run(); + }; + return { + observer, + reads, + replies, + timers, + cancelled, + tick, + get links() { + return links; + }, + }; +} + +export async function settle() { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + +export function pending(offerId = "offer-1"): ConversationPlan.ResearchLinkResult { + return { kind: "conversation-plan:research-link", offerId, status: "pending", ts: 1 }; +} diff --git a/apps/web/src/chat/research-offer.test.ts b/apps/web/src/chat/research-offer.test.ts new file mode 100644 index 00000000..f6ede105 --- /dev/null +++ b/apps/web/src/chat/research-offer.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ResearchOfferCard, shouldShowResearchActionError } from "./research-offer"; +import { deferred } from "./research-offer.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { OfferLinkView } from "./research-offer"; +import type { ResearchRequestStore } from "../research-requests"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, apps/web/src/chat/research-offer.test.ts. +test("Resume appears only for a verified unresolved link and linked work hides stale errors", () => { + let offer: ConversationPlan.ResearchOffer = { + id: "offer-1", + needId: "need-1", + contextId: "context-1", + brief: "Research the hosting options", + status: "accepted", + source: { + messageId: "source-1", + author: { kind: "member", handle: "ana" }, + quote: "Research hosting", + start: 0, + end: 16, + }, + }; + let store = { + subscribe: () => () => {}, + get: () => undefined, + retain: () => () => {}, + } as unknown as ResearchRequestStore; + let render = (link?: OfferLinkView, canAct = true) => + renderToStaticMarkup(createElement(ResearchOfferCard, { + offer, + controls: { + links: link ? { [offer.id]: link } : {}, + busy: new Set(), + errors: { [offer.id]: "Old Resume error" }, + canAct, + canCheckLink: true, + store, + onAction: () => {}, + onRetryLink: () => {}, + }, + })); + for (let link of [undefined, { status: "checking" }, { status: "error" }] as const) { + expect(render(link)).not.toContain(">Resume"); + } + for (let status of ["pending", "unlinked"] as const) { + let link: OfferLinkView = status === "unlinked" + ? { status, researchRequestId: "request-1" } + : { status }; + expect(render(link)).toContain(">Resume"); + } + let linked = render({ status: "linked", researchRequestId: "request-1" }); + expect(linked).not.toContain(">Resume"); + expect(linked).not.toContain("Old Resume error"); + + let failedViewer = render({ status: "error", exhausted: true }, false); + expect(failedViewer).toContain(">Retry link check"); + expect(failedViewer).not.toContain(">Resume"); + expect(render({ status: "error" }, false)).not.toContain(">Retry link check"); +}); + +test("a deferred Resume failure cannot create an error after the link becomes linked", async () => { + let reply = deferred(); + let link: OfferLinkView["status"] = "pending"; + let showError = false; + let completed = reply.promise.catch(() => { + showError = shouldShowResearchActionError("resume", "accepted", link); + }); + link = "linked"; + reply.reject(new Error("old Resume failed")); + await completed; + expect(showError).toBe(false); + expect(shouldShowResearchActionError("resume", "accepted", "unlinked")).toBe(true); + expect(shouldShowResearchActionError("research", "accepted", undefined)).toBe(false); +}); diff --git a/apps/web/src/chat/research-offer.tsx b/apps/web/src/chat/research-offer.tsx new file mode 100644 index 00000000..39ef2f71 --- /dev/null +++ b/apps/web/src/chat/research-offer.tsx @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { SearchIcon } from "@chopin/icons"; + +import type { ConversationPlan, Research } from "@chopin/protocol"; +import type { ResearchRequestStore } from "../research-requests"; +import type { Wire } from "../wire"; + +const RETRY_DELAYS = [2_000, 5_000, 10_000]; + +export type OfferLinkView = { + status: "checking" | "pending" | "unlinked" | "linked" | "error"; + researchRequestId?: string; + exhausted?: boolean; +}; + +export type ResearchOfferControls = { + links: Readonly>; + busy: ReadonlySet; + errors: Readonly>; + canAct: boolean; + canCheckLink: boolean; + store: ResearchRequestStore; + onAction: (offerId: string, choice: "research" | "dismiss" | "resume") => void; + onRetryLink: (offerId: string) => void; +}; + +export function shouldShowResearchActionError( + choice: "research" | "dismiss" | "resume", + offerStatus: ConversationPlan.ResearchOffer["status"] | undefined, + linkStatus: OfferLinkView["status"] | undefined, +): boolean { + return choice === "resume" + ? offerStatus === "accepted" && linkStatus !== "linked" + : offerStatus === "offered"; +} + +type Tracker = { + id: string; + inFlight: boolean; + dirty: boolean; + retries: number; + status: OfferLinkView["status"]; + timer?: ReturnType; +}; + +/** Read-only accepted-offer observer; the timer boundary is injected for controlled-clock tests. */ +export class ResearchOfferLinkObserver { + #trackers = new Map(); + #links: Record = {}; + #disposed = false; + constructor( + private readonly ask: (offerId: string) => Promise, + private readonly publish: (links: Readonly>) => void, + private readonly schedule = (run: () => void, ms: number) => setTimeout(run, ms), + private readonly cancel = (timer: ReturnType) => clearTimeout(timer), + ) {} + + accept(ids: ReadonlySet): void { + if (this.#disposed) return; + let changed = false; + for (let [id, tracker] of this.#trackers) { + if (ids.has(id)) continue; + if (tracker.timer !== undefined) this.cancel(tracker.timer); + this.#trackers.delete(id); + delete this.#links[id]; + changed = true; + } + for (let id of ids) { + if (this.#trackers.has(id)) continue; + this.#trackers.set(id, { + id, + inFlight: false, + dirty: false, + retries: 0, + status: "checking", + }); + this.#links[id] = { status: "checking" }; + changed = true; + this.#read(id); + } + if (changed) this.#publish(); + } + + changed(): void { + for (let tracker of this.#trackers.values()) { + if (tracker.status !== "linked") this.#read(tracker.id); + } + } + + refresh(id: string, restart = false): void { + let tracker = this.#trackers.get(id); + if (!tracker) return; + if (restart && tracker.status !== "linked") { + tracker.retries = 0; + tracker.status = "checking"; + this.#links[id] = { status: "checking" }; + this.#publish(); + } + this.#read(id); + } + + dispose(): void { + this.#disposed = true; + for (let tracker of this.#trackers.values()) { + if (tracker.timer !== undefined) this.cancel(tracker.timer); + } + this.#trackers.clear(); + } + + #publish(): void { + this.publish({ ...this.#links }); + } + + #read(id: string): void { + let tracker = this.#trackers.get(id); + if (!tracker || this.#disposed || tracker.status === "linked") return; + if (tracker.timer !== undefined) this.cancel(tracker.timer); + tracker.timer = undefined; + if (tracker.inFlight) { + tracker.dirty = true; + return; + } + tracker.inFlight = true; + void this.ask(id).then(result => { + if (this.#trackers.get(id) !== tracker) return; + if ( + result.offerId !== id + || !["pending", "unlinked", "linked"].includes(result.status) + || result.status === "pending" && result.researchRequestId !== undefined + || result.status !== "pending" && !result.researchRequestId + ) throw new Error("research link response did not match the offer"); + tracker.status = result.status; + this.#links[id] = { + status: result.status, + ...(result.researchRequestId + ? { researchRequestId: result.researchRequestId } + : {}), + }; + this.#publish(); + }).catch(() => { + if (this.#trackers.get(id) !== tracker) return; + tracker.status = "error"; + this.#links[id] = { status: "error" }; + this.#publish(); + }).finally(() => { + if (this.#trackers.get(id) !== tracker) return; + tracker.inFlight = false; + if (tracker.status === "linked") return; + if (tracker.dirty) { + tracker.dirty = false; + this.#read(id); + return; + } + if (tracker.retries < RETRY_DELAYS.length) { + let delay = RETRY_DELAYS[tracker.retries++]!; + tracker.timer = this.schedule(() => this.#read(id), delay); + } else { + this.#links[id] = { ...this.#links[id]!, exhausted: true }; + this.#publish(); + } + }); + } +} + +/** Observe exact accepted-offer links without starting or resuming research. */ +export function useResearchOfferLinks( + wire: Wire | undefined, + connected: boolean, + offers: readonly ConversationPlan.ResearchOffer[], +): { + links: Readonly>; + refresh: (offerId: string, restart?: boolean) => void; +} { + let [links, setLinks] = useState>>({}); + let observer = useRef(undefined); + useEffect(() => { + if (!wire || !connected) { + setLinks({}); + return; + } + let current = new ResearchOfferLinkObserver( + offerId => + wire.ask( + "conversation-plan:research-link", + { offerId }, + ), + setLinks, + ); + observer.current = current; + let off = wire.on("research:changed", () => current.changed()); + return () => { + off(); + current.dispose(); + if (observer.current === current) observer.current = undefined; + }; + }, [wire, connected]); + useEffect(() => { + observer.current?.accept( + new Set( + offers.filter(offer => offer.status === "accepted") + .map(offer => offer.id), + ), + ); + }, [wire, connected, offers]); + let refresh = useCallback((offerId: string, restart = false) => { + observer.current?.refresh(offerId, restart); + }, []); + return { links, refresh }; +} + +function LinkedResearch({ id, store }: { id: string; store: ResearchRequestStore }) { + let subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]); + let request = useSyncExternalStore(subscribe, () => store.get(id), () => undefined); + useEffect(() => store.retain(id), [id, store]); + if (!request) return

Loading research…

; + return ( +
+ + {request.stage === "ready" + ? "Research ready" + : `Research ${request.stage}`} + + {request.stage === "ready" && request.child && ( + + )} +
+ ); +} + +export function ResearchOfferCard( + { offer, controls }: { offer: ConversationPlan.ResearchOffer; controls: ResearchOfferControls }, +) { + let busy = controls.busy.has(offer.id); + let link = controls.links[offer.id]; + let canResume = link?.status === "pending" || link?.status === "unlinked"; + let canRetryLink = link?.status === "error" && link.exhausted && controls.canCheckLink; + return ( +
+
+
+ {offer.status === "offered" && controls.canAct && ( +
+ + +
+ )} + {offer.status === "dismissed" &&

Dismissed

} + {offer.status === "accepted" && link?.status === "linked" && link.researchRequestId + ? + : offer.status === "accepted" && ( +
+ + {link?.exhausted || link?.status === "error" + ? "Accepted; request link not yet verified" + : link?.status === "unlinked" + ? "Accepted; waiting to start" + : "Research accepted"} + + {controls.canAct && canResume && ( + + )} + {canRetryLink && ( + + )} +
+ )} + {busy &&

Saving…

} + {link?.status !== "linked" && controls.errors[offer.id] && ( +

+ {controls.errors[offer.id]} +

+ )} +
+ ); +} diff --git a/apps/web/src/chat/scoped-choice-entry.test.tsx b/apps/web/src/chat/scoped-choice-entry.test.tsx new file mode 100644 index 00000000..708f1f2d --- /dev/null +++ b/apps/web/src/chat/scoped-choice-entry.test.tsx @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScopedChoicePrompt } from "./scoped-choice-entry"; +import { applyInference, initialState } from "../../../server/src/conversation-plan/domain"; +import { applyEvent } from "../../../server/src/conversation-plan/events"; +import { + cardId, + scopedInput, +} from "../../../server/src/conversation-plan/policy-candidate-scoped.test-fixtures"; + +import type { ComponentProps } from "react"; +import type { Questionnaire } from "@chopin/dialect"; +import type { ConversationPlan } from "@chopin/protocol"; + +type Props = ComponentProps & { value?: Questionnaire }; + +function fixture(): Props { + let input = scopedInput(); + let message = { ...input.message, text: "I'd pick Beta for a spike;" }; + let source: ConversationPlan.SourceRef = { + messageId: message.id, + author: { kind: "member", handle: "Mina" }, + quote: message.text, + start: 0, + end: message.text.length, + role: "support", + }; + let state = applyInference(input.state, { + id: "beta-proposal", + type: "scoped-choice.proposed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source, + cardId, + optionId: "beta", + label: "Beta", + scope: "spike", + }, message); + expect(state.events.reduce(applyEvent, initialState())).toEqual(state); + return { + canEdit: true, + connected: true, + latest: true, + state, + wire: { + ask: async () => { + throw new Error("static render must not submit"); + }, + send: () => {}, + on: () => () => {}, + }, + meta: { + status: "open", + origin: "planner", + involved: [], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, + }, + value: { + id: cardId, + by: "Planner", + questions: [{ + id: "provider", + header: "Provider", + prompt: "Which provider?", + multiple: false, + options: [{ id: "alpha", label: "Alpha" }, { id: "beta", label: "Beta" }], + }], + }, + decision: { + kind: "scoped-choice", + questionnaireId: cardId, + threadId: "provider", + proposalId: "beta-proposal", + cardId, + optionId: "beta", + label: "Beta", + scope: "spike", + generation: 0, + triggerEventId: "beta-proposal", + sources: [source], + }, + }; +} + +function render(props: Props): string { + return renderToStaticMarkup(createElement(ScopedChoicePrompt, props)); +} + +test("a current linked-card option can be saved without a thread contribution", () => { + let props = fixture(); + expect(props.state!.threads[0]!.contributions.map(item => item.id)).toEqual(["alpha"]); + let html = render(props); + expect(html).not.toContain('disabled=""'); + expect(html).not.toContain("This choice has changed."); +}); + +test.each(["missing card", "wrong card", "removed option", "changed label", "duplicate option"])( + "scoped Save fails closed for a %s projection", + kind => { + let props = fixture(); + if (kind === "missing card") props.value = undefined; + else if (kind === "wrong card") props.value!.id = "other-card"; + else if (kind === "removed option") props.value!.questions[0]!.options.pop(); + else if (kind === "changed label") props.value!.questions[0]!.options[1]!.label = "Gamma"; + else props.value!.questions[0]!.options.push({ id: "beta", label: "Beta" }); + expect(render(props)).toContain('disabled=""'); + }, +); + +test.each(["stale generation", "old prompt", "closed card", "disconnected", "read-only"])( + "scoped Save retains its %s guard", + kind => { + let props = fixture(); + if (kind === "stale generation") props.decision.generation = 1; + else if (kind === "old prompt") props.latest = false; + else if (kind === "closed card") props.meta!.status = "discarded"; + else if (kind === "disconnected") props.connected = false; + else props.canEdit = false; + expect(render(props)).toContain('disabled=""'); + }, +); + +test("an already saved scoped choice stays disabled without answering the card", () => { + let props = fixture(); + props.state = applyEvent(props.state!, { + id: "human:Rob:save-beta", + type: "scoped-choice.saved", + threadId: "provider", + observedThreadVersion: props.state!.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Rob" }, + at: 1001, + proposalId: "beta-proposal", + supportEventIds: ["beta-proposal"], + cardId, + optionId: "beta", + label: "Beta", + scope: "spike", + sources: props.decision.sources, + expectedGeneration: 0, + }); + expect(props.state.events.reduce(applyEvent, initialState())).toEqual(props.state); + expect(props.state.threads[0]!.status).toBe("exploring"); + let html = render(props); + expect(html).toContain('disabled=""'); + expect(html).toContain("Saved for this spike"); +}); diff --git a/apps/web/src/chat/scoped-choice-entry.tsx b/apps/web/src/chat/scoped-choice-entry.tsx new file mode 100644 index 00000000..122d734a --- /dev/null +++ b/apps/web/src/chat/scoped-choice-entry.tsx @@ -0,0 +1,141 @@ +/** A provisional spike choice in Chat, separate from the card's final decision. */ + +import { useState } from "react"; +import { DecisionIcon } from "@chopin/icons"; + +import { capitalize } from "./model"; + +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Transport } from "@chopin/question/react"; +import type { Question } from "@chopin/protocol"; +import type { Questionnaire } from "@chopin/dialect"; + +type ScopedChoice = Extract, { kind: "scoped-choice" }>; + +export function ScopedChoicePrompt({ + canEdit, + connected, + decision, + latest, + meta, + state, + value, + wire, +}: { + canEdit: boolean; + connected: boolean; + decision: ScopedChoice; + latest: boolean; + meta?: Question.CardMeta; + state?: ConversationPlan.State; + value?: Questionnaire; + wire?: Transport; +}) { + let [submitting, setSubmitting] = useState(false); + let [acknowledged, setAcknowledged] = useState(false); + let [error, setError] = useState(""); + let thread = state?.threads.find(item => item.id === decision.threadId); + let options = value?.id === decision.cardId + ? value.questions[0]?.options.filter(item => item.id === decision.optionId) + : undefined; + let option = options?.length === 1 ? options[0] : undefined; + let pending = thread?.pendingScopedChoice; + let saved = state?.events.some(event => + event.type === "scoped-choice.saved" && event.proposalId === decision.proposalId + && event.cardId === decision.cardId && event.expectedGeneration === decision.generation + ); + let current = latest && !saved && !!thread && !!pending + && pending.proposalId === decision.proposalId + && pending.cardId === decision.cardId && pending.optionId === decision.optionId + && pending.label === decision.label && pending.scope === decision.scope + && option?.label === decision.label + && thread.questionnaireId === decision.cardId + && (meta?.status === "open" || meta?.status === "reopened") + && meta.history.length === decision.generation; + let enabled = current && canEdit && connected && !!wire && !submitting && !acknowledged; + let actionId = `scoped-choice:${decision.proposalId}:${decision.generation}`; + let save = async () => { + if (!enabled || !thread || !wire) return; + setSubmitting(true); + setError(""); + try { + await wire.ask("conversation-plan:scoped-choice-save", { + actionId, + threadId: decision.threadId, + expectedVersion: thread.version, + proposalId: decision.proposalId, + cardId: decision.cardId, + optionId: decision.optionId, + expectedGeneration: decision.generation, + }); + setAcknowledged(true); + } catch { + setError("Could not save this choice. Check the card and connection, then try again."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + +

+ Save {decision.label} for this spike? +

+
+
+ {decision.sources.map(source => ( +

+ + {source.author.kind === "member" ? capitalize(source.author.handle) : "Planner"} + + {" “"} + {source.quote} + {"”"} +

+ ))} +
+ {!saved && !current && ( +

+ {latest ? "This choice has changed." : "Updated below."} +

+ )} + {current && !canEdit && ( +

+ This document is read-only. +

+ )} + {current && canEdit && !connected && ( +

+ Reconnect to save this choice. +

+ )} + {error &&

{error}

} +
+ +
+
+ ); +} diff --git a/apps/web/src/chat/transcript-decisions.test-fixtures.ts b/apps/web/src/chat/transcript-decisions.test-fixtures.ts new file mode 100644 index 00000000..f84a91bd --- /dev/null +++ b/apps/web/src/chat/transcript-decisions.test-fixtures.ts @@ -0,0 +1,214 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Transcript } from "./transcript"; +import type { CardMetaStore, QuestionnaireStore } from "@chopin/editor"; +import type { Questionnaire } from "@chopin/dialect"; +import type { Chat, ConversationPlan, Question } from "@chopin/protocol"; +import type { Transport } from "@chopin/question/react"; + +// Whole fixture declarations from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +// Scoped snapshot options reflect the current card rather than the unrelated auth fixture. +export const questionnaire: Questionnaire = { + id: "Q", + by: "mina", + questions: [{ + id: "auth", + header: "Auth", + prompt: "What auth system should we use?", + multiple: false, + options: [{ id: "a", label: "Auth0" }, { id: "b", label: "GitHub Apps" }], + }], +}; + +export function prompt(id: string, generation: number): Chat.Entry { + return { + author: { kind: "system" }, + id, + text: "Ready to decide: What auth system should we use?", + ts: 1_700_000_000, + decision: { questionnaireId: "Q", kind: "prompt", generation }, + }; +} + +export let proposalSource: ConversationPlan.SourceRef = { + messageId: "m6", + author: { kind: "member", handle: "mei" }, + quote: "I'd pick Lexical for the spike;", + start: 0, + end: "I'd pick Lexical for the spike;".length, + role: "support", +}; + +export let agreementSource: ConversationPlan.SourceRef = { + messageId: "m7", + author: { kind: "member", handle: "rob" }, + quote: "yep, Lexical for the spike.", + start: 0, + end: "yep, Lexical for the spike.".length, + role: "support", +}; + +export let proposal: ConversationPlan.Event = { + id: "proposal-m6", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1_700_000_000, + source: proposalSource, + cardId: "Q", + optionId: "lexical", + label: "Lexical", + scope: "spike", +}; + +export let agreement: ConversationPlan.Event = { + id: "agreement-m7", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: 3, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1_700_000_001, + source: agreementSource, + proposalId: proposal.id, + cardId: "Q", + optionId: "lexical", + label: "Lexical", + scope: "spike", +}; + +export let saved: ConversationPlan.Event = { + id: "saved-m8", + type: "scoped-choice.saved", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 1_700_000_002, + proposalId: proposal.id, + cardId: "Q", + optionId: "lexical", + label: "Lexical", + scope: "spike", + sources: [proposalSource], + expectedGeneration: 0, +}; + +export let scopedNotice = (sources: ConversationPlan.SourceRef[]): Chat.Entry => ({ + author: { kind: "system" }, + id: `scoped-${sources.at(-1)?.messageId}`, + text: "A scoped choice is ready to save.", + ts: 1_700_000_001, + decision: { + questionnaireId: "Q", + kind: "scoped-choice", + threadId: "thread-a", + proposalId: "proposal-m6", + cardId: "Q", + optionId: "lexical", + label: "Lexical", + scope: "spike", + generation: 0, + triggerEventId: sources.length === 1 ? "proposal-m6" : "agreement-m7", + sources, + }, +}); + +export let scopedPlan = ( + events: ConversationPlan.Event[], + effectiveOptionLabel = "Lexical", +): ConversationPlan.State => ({ + schemaVersion: 1, + revision: events.length, + events, + threads: [{ + id: "thread-a", + question: "Which library should we spike?", + questionSources: [], + questionAuthoring: "quoted", + status: "exploring", + contributions: [{ + id: "lexical", + kind: "option", + text: "Lexical", + ...(effectiveOptionLabel === "Lexical" ? {} : { displayLabel: effectiveOptionLabel }), + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + questionnaireId: "Q", + pendingScopedChoice: { + proposalId: "proposal-m6", + cardId: "Q", + optionId: "lexical", + label: "Lexical", + scope: "spike", + proposer: "mei", + messageId: "m6", + }, + version: 3, + }], + queue: [], + analysis: [], + researchOffers: [], +}); + +export function scopedTranscriptMarkup(entry: Chat.Entry, state: ConversationPlan.State) { + let questions = { + subscribe: () => () => {}, + snapshot: () => [{ + id: "Q", + value: { + ...questionnaire, + questions: [{ + ...questionnaire.questions[0]!, + options: [{ + id: "lexical", + label: state.threads[0]!.contributions[0]!.displayLabel ?? "Lexical", + }], + }], + }, + }], + } as unknown as QuestionnaireStore; + let cardMeta: Question.CardMeta = { + status: "open", + origin: "conversation", + involved: [], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, + }; + let meta = { + subscribe: () => () => {}, + snapshot: () => new Map([["Q", cardMeta]]), + } as unknown as CardMetaStore; + return renderToStaticMarkup(createElement(Transcript, { + active: true, + canEdit: true, + conversationPlan: state, + decisions: { + questions, + meta, + wire: { + ask: async () => undefined as never, + send() {}, + on: () => () => {}, + } as Transport, + connected: true, + canEdit: true, + onOpenCard() {}, + }, + entries: [entry], + handle: "ana", + onWithdraw: () => {}, + queued: [], + })); +} diff --git a/apps/web/src/chat/transcript-decisions.test.tsx b/apps/web/src/chat/transcript-decisions.test.tsx new file mode 100644 index 00000000..34cb4411 --- /dev/null +++ b/apps/web/src/chat/transcript-decisions.test.tsx @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Transcript } from "./transcript"; +import type { CardMetaStore, QuestionnaireStore } from "@chopin/editor"; +import type { Question } from "@chopin/protocol"; +import { prompt, questionnaire } from "./transcript-decisions.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("transcript connects the newest prompt to the shared question and meta snapshots", () => { + let questions = { + subscribe: () => () => {}, + snapshot: () => [{ id: "Q", value: questionnaire }], + } as unknown as QuestionnaireStore; + let meta: Question.CardMeta = { + status: "open", + origin: "conversation", + involved: [], + history: [{ choices: ["a"], owner: "mina", at: 1_700_000_000 }], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, + suggested: { optionId: "b", messageIds: ["m2"], revision: 4 }, + }; + let cardMeta = { + subscribe: () => () => {}, + snapshot: () => new Map([["Q", meta]]), + } as unknown as CardMetaStore; + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + decisions: { + questions, + meta: cardMeta, + connected: false, + canEdit: true, + onOpenCard() {}, + }, + entries: [prompt("old-prompt", 0), prompt("new-prompt", 1)], + handle: "ana", + onWithdraw: () => {}, + queued: [], + })); + + expect(markup).toContain("Superseded by a later prompt"); + expect(markup).toContain("Suggested: GitHub Apps"); + expect(markup).toContain("Decision prompt: What auth system should we use?"); + expect(markup.match(/Save decision/g)).toHaveLength(1); + expect(markup).toContain('disabled=""'); +}); diff --git a/apps/web/src/chat/transcript-scoped-choice.test.tsx b/apps/web/src/chat/transcript-scoped-choice.test.tsx new file mode 100644 index 00000000..5e7b3115 --- /dev/null +++ b/apps/web/src/chat/transcript-scoped-choice.test.tsx @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { + agreement, + agreementSource, + proposal, + proposalSource, + saved, + scopedNotice, + scopedPlan, + scopedTranscriptMarkup, +} from "./transcript-decisions.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a scoped proposal appears as an inline Save action with its proposer source", () => { + let markup = scopedTranscriptMarkup(scopedNotice([proposalSource]), scopedPlan([proposal])); + + expect(markup).toContain("Save for this spike"); + expect(markup).toMatch(/]*disabled)[^>]*>Save for this spike<\/button>/); + expect(markup).toContain("Mei"); + expect(markup).toContain("I'd pick Lexical for the spike;"); + expect(markup).not.toContain("Save decision"); + expect(markup).not.toContain("Decision prompt:"); +}); + +test("an agreement refresh shows both exact source authors and quotes", () => { + let markup = scopedTranscriptMarkup( + scopedNotice([proposalSource, agreementSource]), + scopedPlan([proposal, agreement]), + ); + + expect(markup).toContain("Save for this spike"); + expect(markup).toMatch(/]*disabled)[^>]*>Save for this spike<\/button>/); + expect(markup).toContain("Mei"); + expect(markup).toContain("Rob"); + expect(markup).toContain("I'd pick Lexical for the spike;"); + expect(markup).toContain("yep, Lexical for the spike."); + expect(markup).not.toContain("Save decision"); + expect(markup).not.toContain("Decision prompt:"); +}); + +test("a durable scoped save disables the historical notice action", () => { + let markup = scopedTranscriptMarkup( + scopedNotice([proposalSource]), + scopedPlan([proposal, saved]), + ); + + expect(markup).toContain("Saved for this spike"); + expect(markup).toMatch(/]*disabled=""[^>]*>Saved for this spike<\/button>/); + expect(markup).not.toContain("Save decision"); +}); + +test("a relabeled option disables the historical scoped Save action", () => { + let relabeled: ConversationPlan.Event = { + id: "option-relabel:rename-m9", + type: "option.relabeled", + threadId: "thread-a", + observedThreadVersion: 3, + observedCardRevision: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1_700_000_003, + optionId: "lexical", + label: "JavaScript Lexical editor", + }; + let markup = scopedTranscriptMarkup( + scopedNotice([proposalSource, agreementSource]), + scopedPlan([proposal, agreement, relabeled], "JavaScript Lexical editor"), + ); + + expect(markup).toContain("This choice has changed."); + expect(markup).toMatch( + /]*disabled=""[^>]*>Save for this spike<\/button>/, + ); +}); diff --git a/apps/web/src/chat/transcript-source.test.tsx b/apps/web/src/chat/transcript-source.test.tsx new file mode 100644 index 00000000..51457f4e --- /dev/null +++ b/apps/web/src/chat/transcript-source.test.tsx @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Transcript } from "./transcript"; +import type { ChatDestination } from "../conversation-plan/source"; + +let destination: ChatDestination = { + itemId: "thread", + token: 1, + source: { + messageId: "saved", + author: { kind: "member", handle: "ana" }, + role: "option", + quote: "A\u{1F9EA} pilot", + start: 0, + end: 9, + }, +}; + +function markup(sourceDestination?: ChatDestination, text = "A\u{1F9EA} pilot") { + return renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [{ id: "saved", author: { kind: "member", handle: "ana" }, text, ts: 1 }], + handle: "ana", + onWithdraw: () => {}, + queued: [], + sourceDestination, + })); +} + +test("saved text and its rendered container retain exact UTF-16 source metadata", () => { + let result = markup(); + expect(result).toContain('data-chat-raw="A\u{1F9EA} pilot"'); + expect(result).toContain("data-chat-message-text"); + expect(result).not.toContain("data-source-preview"); + expect(result).not.toContain("data-source-exact"); +}); + +test("only the destination message gets its exact source preview and target class", () => { + let selected = markup(destination); + expect(selected).toContain("rounded-md bg-inset px-1"); + expect(selected).toContain("data-source-preview"); + expect(selected).toContain("Source: “A\u{1F9EA} pilot”"); + let missing = markup({ ...destination, source: { ...destination.source, messageId: "other" } }); + expect(missing).not.toContain("data-source-preview"); + expect(missing).not.toContain("rounded-md bg-inset px-1"); +}); + +test("source metadata preserves current Markdown rendering instead of flattening text", () => { + let result = markup(destination, "**A\u{1F9EA} pilot**"); + expect(result).toContain('data-chat-raw="**A\u{1F9EA} pilot**"'); + expect(result).toContain("A\u{1F9EA} pilot"); +}); diff --git a/apps/web/src/chat/transcript.test.tsx b/apps/web/src/chat/transcript.test.tsx index 675be4fd..9756dcb5 100644 --- a/apps/web/src/chat/transcript.test.tsx +++ b/apps/web/src/chat/transcript.test.tsx @@ -3,6 +3,16 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Transcript } from "./transcript"; +import { childDocumentPath } from "@chopin/protocol/document-url"; +import { + answerArtifact, + evidenceArtifact, + settle, + setup, +} from "../../../server/src/research/test-support"; +import { terminalNotices } from "../../../server/src/research/inline.test-fixtures"; + +import type { Chat } from "@chopin/protocol"; test("system presence entries stay immediate when they arrive", () => { let markup = renderToStaticMarkup( @@ -40,3 +50,153 @@ test("queued messages use the standard icon-button glyph", () => { expect(markup).toContain("xmark"); expect(markup).not.toContain(">×"); }); + +test("a ready research notice links to its child document while other notices stay plain text", () => { + let childPath = "/documents/octo-org/score/parent/children/research"; + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [ + { + author: { kind: "system" }, + id: "ready", + text: `Research is ready. [Open the research document](${childPath}).`, + ts: 1_700_000_000, + }, + { + author: { kind: "system" }, + id: "failed", + text: "Research could not be completed. You can retry it from the research card.", + ts: 1_700_000_001, + }, + ], + handle: "ana", + onWithdraw: () => {}, + queued: [], + })); + + expect(markup).toContain(`Open the research document"); + expect(markup).toContain( + "Research could not be completed. You can retry it from the research card.", + ); + expect(markup.match(/data-chat-markdown/g)).toHaveLength(1); +}); + +test("system notices with arbitrary Markdown remain text", () => { + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [{ + author: { kind: "system" }, + id: "other", + text: "[Outside](https://example.com) unsafe", + ts: 1_700_000_000, + }], + handle: "ana", + onWithdraw: () => {}, + queued: [], + })); + + expect(markup).not.toContain(""); + expect(markup).toContain("[Outside](https://example.com) <b>unsafe</b>"); +}); + +test("a ready notice cannot turn an external destination into a system link", () => { + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [{ + author: { kind: "system" }, + id: "outside", + text: "Research is ready. [Open the research document](https://example.com).", + ts: 1_700_000_000, + }], + handle: "ana", + onWithdraw: () => {}, + queued: [], + })); + + expect(markup).not.toContain(" { + let context = await setup(); + let started = await context.service.startPlannerInline({ + channelId: context.channelId, + question: "Check the release", + originMessageId: "message-transcript-ready", + requestedBy: context.userId, + placeReference: async () => "placed", + }); + let evidence = await settle(context, "research-evidence", evidenceArtifact); + await context.service.jobChanged(evidence.job); + let answer = await settle(context, "research-answer", answerArtifact); + let { service, notices } = terminalNotices(context); + await service.jobChanged(answer.job); + let request = await service.request(context.channelId, started.request.id); + if (request?.stage !== "ready") throw new Error("published research child is not ready"); + let path = childDocumentPath( + context.channel.repositoryOwner, + context.channel.repositoryName, + context.channel.slug, + request.child.slug, + ); + expect(notices).toHaveLength(1); + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: notices.map((notice): Chat.Entry => ({ + ...notice, + author: { kind: "system" }, + ts: 1, + })), + handle: "ana", + onWithdraw() {}, + queued: [], + })); + + expect(markup).toContain(`Open the research document"); + expect(markup).toContain('class="chat-markdown min-w-0 break-words text-sm'); + expect(markup).not.toContain("[Open the research document]"); +}); + +test.each([ + "/documents/octo-org/score/parent", + "/documents/octo-org/score/parent/children/", + "/documents/octo-org/score/parent/children/%ZZ", + "/documents/octo-org/score/parent/children/research?next=https://example.com", + "/documents/octo-org/score/parent/children/research#report", + "/documents/octo-org/score/parent/children/research/extra", + "//example.com/documents/octo-org/score/parent/children/research", + "javascript:alert(1)", +])("ready notices with an invalid child route stay text: %s", path => { + let text = `Research is ready. [Open the research document](${path}).`; + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [{ author: { kind: "system" }, id: "invalid", text, ts: 1 }], + handle: "ana", + onWithdraw() {}, + queued: [], + })); + + expect(markup).not.toContain(" { + let text = "Research is ready. [Open the research document]" + + "(/documents/octo-org/score/parent/children/research). [Extra](https://example.com)."; + let markup = renderToStaticMarkup(createElement(Transcript, { + active: true, + entries: [{ author: { kind: "system" }, id: "extra", text, ts: 1 }], + handle: "ana", + onWithdraw() {}, + queued: [], + })); + + expect(markup).not.toContain(" void; + onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; + onRetryAnalysis?: (messageId: string, actionId: string) => Promise; + onRetryJob?: (jobId: string) => Promise; + sourceDestination?: ChatDestination; + conversationPlan?: ConversationPlan.State; + researchOffers?: ResearchOfferControls; +}; + +export type TranscriptDecisions = { + questions: QuestionnaireStore; + meta: CardMetaStore; + wire?: Transport; + connected: boolean; + canEdit: boolean; + onOpenCard: (questionnaireId: string) => void; +}; function when(ts: number): string { return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); @@ -93,34 +132,108 @@ function ToolRun({ tools }: { tools: Chat.Activity[] }) { } function SystemEntry({ item }: { item: Extract }) { + let readyPath = /^Research is ready\. \[Open the research document\]\((\/documents\/\S+)\)\.$/ + .exec(item.text)?.[1]; + let linked = readyPath !== undefined && parseChildDocumentPath(readyPath) !== undefined; return (
-

- {displayText(item.text)} -

+ {linked + ? ( + + ) + : ( +

+ {displayText(item.text)} +

+ )}
); } +function DecisionSystemEntry( + { conversationPlan, decisions, item, latest }: { + conversationPlan?: ConversationPlan.State; + decisions: TranscriptDecisions; + item: Extract & { decision: NonNullable }; + latest: boolean; + }, +) { + let id = item.decision.questionnaireId; + let values = useSyncExternalStore( + decisions.questions.subscribe, + decisions.questions.snapshot, + decisions.questions.snapshot, + ); + let value = values.find(entry => entry.id === id)?.value; + let meta = useCardMeta(decisions.meta, id); + let entry: Chat.Entry & { decision: NonNullable } = { + id: item.id, + author: { kind: "system" }, + text: item.text, + ts: item.ts!, + decision: item.decision, + }; + let props = { + entry, + latest, + value, + meta, + wire: decisions.wire, + connected: decisions.connected, + canEdit: decisions.canEdit, + onOpenCard: decisions.onOpenCard, + }; + + if (entry.decision.kind === "scoped-choice") { + return ( + + ); + } + return entry.decision.kind === "prompt" + ? + : ; +} + function MessageBody( - { handle, message, onWithdraw }: { + { handle, message, onWithdraw, ...markers }: { handle: string; message: Message; onWithdraw: (id: string) => void; - }, + } & PlanMarkers, ) { let text = displayText(message.text) ? message.text : message.author.kind === "member" ? "Ask Planner" : ""; return ( -
+
{text && (
-
+
)} {message.tools && message.tools.length > 0 && } + {!message.queued && markers.researchOffers && markers.conversationPlan?.researchOffers + ?.filter(offer => offer.source.messageId === message.id) + .map(offer => ( + + ))} + {markers.sourceDestination?.source.messageId === message.id && ( +

+ Source: “{markers.sourceDestination.source.quote}” +

+ )} + {!message.queued && markers.onCardLink && markers.onRetryAnalysis && ( + + )}
); } function MessageGroup( - { group: item, handle, onWithdraw }: { + { group: item, handle, onWithdraw, ...markers }: { group: Extract; handle: string; onWithdraw: (id: string) => void; - }, + } & PlanMarkers, ) { let first = item.messages[0]!; let name = item.author.kind === "agent" ? "Planner" : capitalize(item.author.handle); @@ -179,7 +315,21 @@ function MessageGroup(
{item.messages.map(message => ( - + ))}
@@ -189,32 +339,82 @@ function MessageGroup( export function Transcript( { active, + canEdit, + conversationPlan, + conversationPlanJobs, + decisions, + researchOffers, entries, handle, + onCardLink, + onAddExcerpt, + onRetryAnalysis, + onRetryJob, onWithdraw, queued, + sourceDestination, working, }: { active: boolean; + canEdit?: boolean; + conversationPlan?: ConversationPlan.State; + conversationPlanJobs?: ConversationPlan.Job[]; + onCardLink?: (link: CardLink) => void; + onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; + onRetryAnalysis?: (messageId: string, actionId: string) => Promise; + onRetryJob?: (jobId: string) => Promise; + decisions?: TranscriptDecisions; + researchOffers?: ResearchOfferControls; entries: Chat.Entry[]; handle: string; onWithdraw: (id: string) => void; queued: Chat.Waiting[]; working?: Pick; + sourceDestination?: ChatDestination; }, ) { let bottom = useRef(null); + let scroller = useRef(null); let pinned = useRef(true); + let sourceOwner = useRef({}); let groups = group(entries, queued, working); + let latestPrompt = new Map(); + let latestScoped = new Map(); + for (let entry of entries) { + if (entry.decision?.kind === "prompt") { + latestPrompt.set(entry.decision.questionnaireId, entry.id); + } + if (entry.decision?.kind === "scoped-choice") { + latestScoped.set(entry.decision.proposalId, entry.id); + } + } useEffect(() => { if (active && pinned.current) bottom.current?.scrollIntoView({ block: "end" }); }, [active, entries, queued]); + useEffect(() => { + if (!active || !sourceDestination) return; + let message = Array.from( + scroller.current?.querySelectorAll("[data-chat-message-id]") ?? [], + ) + .find(element => element.dataset.chatMessageId === sourceDestination.source.messageId); + if (!message) return; + pinned.current = false; + message.scrollIntoView({ block: "center", inline: "nearest" }); + let exact = highlightSource(sourceOwner.current, message, sourceDestination.source); + message.dataset.sourceExact = String(exact); + return () => { + clearSourceHighlight(sourceOwner.current); + delete message.dataset.sourceExact; + }; + }, [active, sourceDestination]); + return (
{ let element = event.currentTarget; let distance = element.scrollHeight - element.scrollTop - element.clientHeight; @@ -227,13 +427,38 @@ export function Transcript( > {groups.map(item => item.kind === "system" - ? + ? decisions && item.decision && item.ts !== undefined + ? ( + & { + decision: NonNullable; + }} + key={item.id} + latest={item.decision.kind === "prompt" + ? latestPrompt.get(item.decision.questionnaireId) === item.id + : item.decision.kind === "scoped-choice" + ? latestScoped.get(item.decision.proposalId) === item.id + : true} + /> + ) + : : ( ) )} diff --git a/apps/web/src/conversation-plan/analysis-action.ts b/apps/web/src/conversation-plan/analysis-action.ts new file mode 100644 index 00000000..5cbc5804 --- /dev/null +++ b/apps/web/src/conversation-plan/analysis-action.ts @@ -0,0 +1,8 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +export type ExcerptCorrectionAction = { + actionId: string; + threadId: string; + expectedVersion: number; + change: Extract; +}; diff --git a/apps/web/src/conversation-plan/analysis-labels.ts b/apps/web/src/conversation-plan/analysis-labels.ts new file mode 100644 index 00000000..4065294d --- /dev/null +++ b/apps/web/src/conversation-plan/analysis-labels.ts @@ -0,0 +1,32 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +export const SIGNALS: Record = { + new_question: "Question", + new_option: "Proposal", + reason: "Reason", + constraint: "Constraint", + evidence: "Evidence", + assumption: "Assumption", + support: "Support", + objection: "Objection", + correction: "Correction", + explicit_resolution: "Resolution", + reopening: "Reopening", +}; + +export function answerText(answer: ConversationPlan.AnalysisAnswer): string { + if (answer.type === "noul") return `${Math.round(answer.noul * 100)}% signal`; + if (answer.type === "choice") { + return `${answer.choice.replaceAll("_", " ")} · ${ + Math.round(answer.confidence * 100) + }% confidence`; + } + return `${answer.score} · ${Math.round(answer.confidence * 100)}% confidence${ + answer.legend[String(answer.score)] ? ` · ${answer.legend[String(answer.score)]}` : "" + }`; +} + +export function questionName(key: string): string { + return key.replace(/^c(\d+)_/, (_, number: string) => `Excerpt ${Number(number) + 1} · `) + .replaceAll("_", " "); +} diff --git a/apps/web/src/conversation-plan/analysis-overview.test.tsx b/apps/web/src/conversation-plan/analysis-overview.test.tsx new file mode 100644 index 00000000..2100d222 --- /dev/null +++ b/apps/web/src/conversation-plan/analysis-overview.test.tsx @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AnalysisOverview } from "./analysis-overview"; + +import type { ConversationPlan } from "@chopin/protocol"; + +test("analysis leads with applied and held excerpts, then explains Jev and policy", () => { + let messageText = "We should use S3, but it must be encrypted."; + let first = "We should use S3"; + let second = "it must be encrypted"; + let analysis: ConversationPlan.AnalysisRecord = { + messageId: "message-1", + questionSetVersion: "conversation-plan-5", + modelVersion: "jev-1", + status: "applied", + passes: [{ + stage: "triage", + answers: { + new_option: { type: "noul", noul: 0.94 }, + constraint: { type: "noul", noul: 0.86 }, + }, + }], + outcomes: [{ + start: messageText.indexOf(first), + end: messageText.indexOf(first) + first.length, + status: "accepted", + gate: "accepted", + eventIds: ["event-1"], + }, { + start: messageText.indexOf(second), + end: messageText.indexOf(second) + second.length, + status: "review", + gate: "contribution target needs review", + eventIds: [], + }], + eventIds: ["event-1"], + policyGate: "0:accepted; 1:contribution target needs review", + }; + let html = renderToStaticMarkup(createElement(AnalysisOverview, { + analysis, + links: [], + messageText, + status: "applied", + })); + + expect(html).toContain("1 finding applied"); + expect(html).toContain("Jev detected"); + expect(html).toContain("Proposal"); + expect(html).toContain("Constraint"); + expect(html).toContain(`“${first}”`); + expect(html).toContain(`“${second}”`); + expect(html).toContain("contribution target needs review"); + expect(html).toContain("Model answers and run details"); + expect(html).toContain("94% signal"); + expect(html).not.toContain("{"type""); +}); diff --git a/apps/web/src/conversation-plan/analysis-overview.tsx b/apps/web/src/conversation-plan/analysis-overview.tsx new file mode 100644 index 00000000..b08f3bfd --- /dev/null +++ b/apps/web/src/conversation-plan/analysis-overview.tsx @@ -0,0 +1,240 @@ +import { CheckIcon } from "@chopin/icons"; + +import { ExcerptCorrection } from "./excerpt-correction"; +import { answerText, questionName, SIGNALS } from "./analysis-labels"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { CardLink } from "./links"; +import type { ExcerptCorrectionAction } from "./analysis-action"; + +export type { ExcerptCorrectionAction } from "./analysis-action"; + +export function AnalysisOverview({ + analysis, + canEdit = false, + links, + messageId = "", + messageText, + onAddExcerpt, + state, + status, +}: { + analysis?: ConversationPlan.AnalysisRecord; + canEdit?: boolean; + links: CardLink[]; + messageId?: string; + messageText: string; + onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; + state?: ConversationPlan.State; + status: ConversationPlan.AnalysisRecord["status"]; +}) { + let outcomes = analysis?.outcomes ?? []; + let applied = outcomes.filter(outcome => outcome.status === "accepted").length; + let targetIds = [...new Set(outcomes.map(outcome => outcome.targetId).filter(Boolean))]; + if (targetIds.length === 0) targetIds = [...new Set(links.map(link => link.threadId))]; + let target = targetIds.length === 1 + ? state?.threads.find(thread => thread.id === targetIds[0])?.question + : targetIds.length > 1 + ? `${targetIds.length} decisions` + : undefined; + let heading = status === "applied" + ? outcomes.length ? `${applied} finding${applied === 1 ? "" : "s"} applied` : "Plan updated" + : status === "unlinked" + ? "No plan change" + : status === "failed" + ? "Analysis failed" + : status === "running" + ? "Jev is analysing" + : "Waiting for analysis"; + let triage = analysis?.passes.find(pass => pass.stage === "triage"); + let signals = Object.entries(triage?.answers ?? {}) + .filter(([key, answer]) => SIGNALS[key] && answer.type === "noul" && answer.noul >= 0.55) + .sort((a, b) => (b[1] as { noul: number }).noul - (a[1] as { noul: number }).noul) + .map(([key]) => SIGNALS[key]); + let rows = outcomes.map((outcome, index) => { + let completeLink = links.find(item => + item.source.start === outcome.start && item.source.end === outcome.end + ); + let linkedSubspans = links.filter(item => + item.source.start >= outcome.start && item.source.end <= outcome.end + && (item.source.start !== outcome.start || item.source.end !== outcome.end) + ).map(item => item.source); + let link = completeLink + ?? links.find(item => item.source.start >= outcome.start && item.source.end <= outcome.end); + let role = analysis?.passes.find(pass => pass.stage === "targeting")?.answers[ + `c${index}_role` + ]; + let label = link?.label ?? (role?.type === "choice" + ? role.choice.replaceAll("_", " ") + : "Excerpt"); + let quote = messageText.slice(outcome.start, outcome.end); + return { + outcome, + label, + link, + completeLink: !!completeLink, + linkedSubspans, + quote: quote || link?.source.quote || `Excerpt ${index + 1}`, + }; + }); + if (rows.length === 0 && links.length > 0) { + rows = links.map(link => ({ + outcome: { + start: link.source.start, + end: link.source.end, + status: "accepted" as const, + gate: "accepted", + eventIds: [], + }, + label: link.label, + link, + completeLink: true, + linkedSubspans: [], + quote: link.source.quote, + })); + } + let review = rows.filter(row => row.outcome.status === "review" && !row.completeLink).length; + let ignored = rows.filter(row => row.outcome.status === "ignored" && !row.completeLink).length; + + return ( + <> +
+
+

+ {status === "applied" + ? ( + <> + Chopin linked this message{target + ? ( + <> + to {target} + + ) + : ""}. + + ) + : status === "unlinked" + ? "Chopin did not apply a card change from this message." + : status === "failed" + ? analysis?.error || "Jev could not complete this analysis." + : "The message has not finished processing yet."} +

+ {signals.length > 0 && ( +
+

Jev detected

+
+ {signals.map(signal => ( + + {signal} + + ))} +
+
+ )} + {rows.length > 0 && ( +
+ {rows.map(({ outcome, label, link, completeLink, linkedSubspans, quote }, index) => ( +
+ +
+
+ {label} + + {link && outcome.status !== "accepted" + ? "Added to card" + : outcome.status === "accepted" + ? "Applied" + : outcome.status === "review" + ? "Review" + : "Not applied"} + +
+

“{quote}”

+ {outcome.status !== "accepted" && !link && ( +

{outcome.gate}

+ )} + {["review", "ignored"].includes(outcome.status) && ( + + )} +
+
+ ))} +
+ )} + {outcomes.length > 0 && (review > 0 || ignored > 0) && ( +

+ {review} held for review · {ignored} not applied +

+ )} + {analysis && ( +
+ + Model answers and run details + +
+

+ Model {analysis.modelVersion || "not recorded"} · Questions{" "} + {analysis.questionSetVersion || "not recorded"} + {analysis.latencyMs === undefined ? "" : ` · ${analysis.latencyMs} ms`} +

+ {analysis.policyGate &&

Policy: {analysis.policyGate}

} + {analysis.quoteValidation && ( +

+ Source quotes:{" "} + {analysis.quoteValidation.filter(range => range.valid).length}/{analysis + .quoteValidation.length} valid +

+ )} + {analysis.passes.map((pass, index) => ( +
+

{pass.stage}

+
+ {Object.entries(pass.answers).map(([key, answer]) => ( +
+
{questionName(key)}
+
+ {answerText(answer)} +
+
+ ))} +
+
+ ))} +
+
+ )} + + ); +} diff --git a/apps/web/src/conversation-plan/announcements.test.ts b/apps/web/src/conversation-plan/announcements.test.ts new file mode 100644 index 00000000..48d3ad8d --- /dev/null +++ b/apps/web/src/conversation-plan/announcements.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; + +import { advanceConversationAnnouncement } from "./announcements"; + +import type { ConversationPlan } from "@chopin/protocol"; + +function event( + type: "thread.opened" | "card.linked" | "thread.discarded", + id: string = type, +): ConversationPlan.Event { + let base: ConversationPlan.EventBase = { + id, + threadId: "thread-1", + observedThreadVersion: 1, + origin: "human", + actor: { kind: "member", handle: "ada" }, + at: 1, + }; + if (type === "thread.opened") return { ...base, type, question: "Which storage?" }; + if (type === "card.linked") return { ...base, type, questionnaireId: "card-1" }; + return { ...base, type }; +} + +function state( + revision: number, + queue: ConversationPlan.QueueItem[] = [], + events: ConversationPlan.Event[] = [], +): ConversationPlan.State { + return { schemaVersion: 1, revision, events, threads: [], queue, analysis: [] }; +} + +function failed(messageId: string): ConversationPlan.QueueItem { + return { messageId, status: "failed", attempts: 1, error: "analysis failed" }; +} + +function baseline(value: ConversationPlan.State) { + return advanceConversationAnnouncement(undefined, value).summary; +} + +describe("conversation announcements", () => { + it("establishes an initial silent baseline even with historical failures and events", () => { + let initial = state(10, [ + failed("a"), + { messageId: "pending", status: "pending", attempts: 1 }, + { messageId: "processing", status: "processing", attempts: 1 }, + ], [event("thread.discarded")]); + + expect(advanceConversationAnnouncement(undefined, initial)).toEqual({ + summary: { revision: 10, events: 1, failedMessages: ["a"] }, + }); + expect(advanceConversationAnnouncement(baseline(initial), undefined)).toEqual({}); + }); + + it("keeps persistent failures quiet despite attempts, errors and queue order changes", () => { + let previous = baseline(state(1, [failed("a"), failed("b")])); + let next = state(2, [ + { ...failed("b"), attempts: 4, error: "different error" }, + { ...failed("a"), attempts: 2 }, + ]); + + expect(advanceConversationAnnouncement(previous, next)).toEqual({ + summary: { revision: 2, events: 0, failedMessages: ["b", "a"] }, + }); + }); + + it("announces newly failed message IDs while existing failures remain", () => { + let result = advanceConversationAnnouncement( + baseline(state(1, [failed("a")])), + state(2, [failed("a"), failed("b")]), + ); + + expect(result.message).toBe("Message analysis failed. You can retry from Chat."); + expect(result.summary).toEqual({ revision: 2, events: 0, failedMessages: ["a", "b"] }); + }); + + it("allows a retry to fail again after its pending observation", () => { + let previous = baseline(state(1, [failed("a")])); + let retry = advanceConversationAnnouncement( + previous, + state(2, [{ + messageId: "a", + status: "pending", + attempts: 2, + }]), + ); + let failure = advanceConversationAnnouncement(retry.summary, state(3, [failed("a")])); + + expect(retry.message).toBeUndefined(); + expect(failure.message).toBe("Message analysis failed. You can retry from Chat."); + expect(advanceConversationAnnouncement(failure.summary, state(3, [failed("a")])).message) + .toBeUndefined(); + }); + + it("gives a new failure priority over event growth without replaying the card update", () => { + let batch = [event("thread.discarded"), event("card.linked")]; + let failure = advanceConversationAnnouncement( + baseline(state(1)), + state(2, [failed("a")], batch), + ); + let quiet = advanceConversationAnnouncement( + failure.summary, + state(3, [failed("a")], batch), + ); + + expect(failure.message).toBe("Message analysis failed. You can retry from Chat."); + expect(failure.summary?.events).toBe(2); + expect(quiet.message).toBeUndefined(); + }); + + it("announces accepted event growth only at a higher revision", () => { + let growth = advanceConversationAnnouncement( + baseline(state(1)), + state(2, [], [event("thread.discarded")]), + ); + + expect(growth.message).toBe("A conversation card was updated."); + expect( + advanceConversationAnnouncement( + growth.summary, + state(3, [], [event("thread.discarded", "replacement")]), + ).message, + ).toBeUndefined(); + expect(advanceConversationAnnouncement(growth.summary, state(3)).message).toBeUndefined(); + }); + + it.each(["thread.opened", "card.linked"] as const)( + "suppresses the entire growing event batch when its final event is %s", + type => { + let batch = [event("thread.discarded"), event(type)]; + let excluded = advanceConversationAnnouncement(baseline(state(1)), state(2, [], batch)); + let later = advanceConversationAnnouncement( + excluded.summary, + state(3, [], [...batch, event("thread.discarded", "later")]), + ); + + expect(excluded.message).toBeUndefined(); + expect(excluded.summary?.events).toBe(2); + expect(later.message).toBe("A conversation card was updated."); + }, + ); + + it.each([2, 1])( + "captures revision %s without announcing equal or lower observations", + revision => { + let observed = state(revision, [failed("a")], [event("thread.discarded")]); + let result = advanceConversationAnnouncement(baseline(state(2)), observed); + + expect(result).toEqual({ + summary: { revision, events: 1, failedMessages: ["a"] }, + }); + expect( + advanceConversationAnnouncement(result.summary, state(3, observed.queue, observed.events)) + .message, + ).toBeUndefined(); + }, + ); + + it("detaches captured counts and failed IDs from later mutation of the observed state", () => { + let observed = state(1, [failed("a")], [event("thread.discarded")]); + let result = advanceConversationAnnouncement(undefined, observed); + observed.revision = 8; + observed.queue[0]!.messageId = "mutated"; + observed.queue.push(failed("b")); + observed.events.push(event("card.linked")); + + expect(result.summary).toEqual({ revision: 1, events: 1, failedMessages: ["a"] }); + }); +}); diff --git a/apps/web/src/conversation-plan/announcements.ts b/apps/web/src/conversation-plan/announcements.ts new file mode 100644 index 00000000..bec9ed19 --- /dev/null +++ b/apps/web/src/conversation-plan/announcements.ts @@ -0,0 +1,27 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +export type ConversationAnnouncementSummary = { + revision: number; + events: number; + failedMessages: string[]; +}; + +export function advanceConversationAnnouncement( + previous: ConversationAnnouncementSummary | undefined, + state: ConversationPlan.State | undefined, +): { summary?: ConversationAnnouncementSummary; message?: string } { + if (!state) return {}; + let failedMessages = state.queue.filter(item => item.status === "failed") + .map(item => item.messageId); + let summary = { revision: state.revision, events: state.events.length, failedMessages }; + if (!previous || state.revision <= previous.revision) return { summary }; + if (failedMessages.some(messageId => !previous.failedMessages.includes(messageId))) { + return { summary, message: "Message analysis failed. You can retry from Chat." }; + } + if (state.events.length > previous.events) { + let latest = state.events.at(-1); + if (latest?.type === "thread.opened" || latest?.type === "card.linked") return { summary }; + return { summary, message: "A conversation card was updated." }; + } + return { summary }; +} diff --git a/apps/web/src/conversation-plan/card-parts.tsx b/apps/web/src/conversation-plan/card-parts.tsx new file mode 100644 index 00000000..80729057 --- /dev/null +++ b/apps/web/src/conversation-plan/card-parts.tsx @@ -0,0 +1,29 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +export type SourceDestination = { source: ConversationPlan.SourceRef; itemId: string }; + +export function Sources({ itemId, onSource, sources }: { + itemId: string; + onSource: (destination: SourceDestination) => void; + sources: ConversationPlan.SourceRef[]; +}) { + if (sources.length === 0) return null; + return ( + + {sources.map((source, index) => ( + + ))} + + ); +} diff --git a/apps/web/src/conversation-plan/evidence-popover.test.tsx b/apps/web/src/conversation-plan/evidence-popover.test.tsx new file mode 100644 index 00000000..9ba912e0 --- /dev/null +++ b/apps/web/src/conversation-plan/evidence-popover.test.tsx @@ -0,0 +1,151 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { EvidencePopover } from "./evidence-popover"; + +import type { EvidenceRow } from "./evidence"; + +const ROWS: EvidenceRow[] = [ + { + optionId: "github", + label: "GitHub Apps", + origin: "chat", + supporters: ["mina", "jules"], + opposers: ["lee"], + items: [{ + id: "r1", + kind: "reason", + text: "People already have GitHub accounts.", + sources: [{ + messageId: "m1", + author: { kind: "member", handle: "mina" }, + quote: "People already have GitHub accounts.", + start: 0, + end: 36, + role: "reason", + }], + }], + }, + { + optionId: "sso", + label: "Enterprise SSO", + origin: "planner", + supporters: [], + opposers: [], + items: [], + rationale: "Enterprise customers expect SAML.", + }, +]; + +test("each row shows its label, stances, sourced evidence and Planner rationale", () => { + let markup = renderToStaticMarkup( + createElement(EvidencePopover, { rows: ROWS, onSource: () => {} }), + ); + + expect(markup).toContain('aria-label="Evidence"'); + expect(markup).toContain("GitHub Apps"); + expect(markup).toContain("Supported by mina, jules"); + expect(markup).toContain("Opposed by lee"); + expect(markup).toContain("ring-2 ring-destructive"); + expect(markup).toContain("People already have GitHub accounts."); + expect(markup).toContain('aria-label="Show “People already have GitHub accounts.” in chat"'); + expect(markup).toContain("Planner suggested"); + expect(markup).toContain("Why Chopin suggested this: Enterprise customers expect SAML."); + expect(markup).not.toMatch(/<(input|select|textarea)\b/); +}); + +test("shows at most eight avatar images while naming every participant accessibly", () => { + let handles = ["mina", "jules", "lee", "ana", "bo", "cy", "di", "eve", "flo", "gus"]; + let opponents = ["nora", "otto", "paz", "quinn", "ravi", "suki", "tali", "uma", "viktor"]; + let row: EvidenceRow = { + optionId: "many", + label: "A crowded option", + origin: "chat", + supporters: handles, + opposers: opponents, + items: [], + }; + let markup = renderToStaticMarkup( + createElement(EvidencePopover, { rows: [row], onSource: () => {} }), + ); + + expect(markup.match(/+2"); + expect(markup).toContain(">+1"); +}); + +test("omits source controls without sources and hides non-Planner rationale", () => { + let row: EvidenceRow = { + optionId: "human", + label: "Human option", + origin: "human", + supporters: [], + opposers: [], + items: [{ + id: "c1", + kind: "constraint", + text: "Must work offline.", + sources: [], + }], + rationale: "Only a Planner rationale belongs here.", + }; + let markup = renderToStaticMarkup( + createElement(EvidencePopover, { rows: [row], onSource: () => {} }), + ); + + expect(markup).toContain("Constraint"); + expect(markup).toContain("Must work offline."); + expect(markup).not.toContain("Show “Must work offline.” in chat"); + expect(markup).not.toContain("Why Chopin suggested this:"); +}); + +test("shows an exact attributed quote for a sourced Planner option", () => { + let row: EvidenceRow = { + optionId: "jev", + label: "Invite Jev", + origin: "planner", + supporters: [], + opposers: [], + items: [], + rationale: "Jev can review the plan.", + source: { + messageId: "m-jevi", + author: { kind: "member", handle: "mina" }, + quote: "Jev needs access to the release plan.", + start: 4, + end: 41, + role: "option", + }, + }; + let markup = renderToStaticMarkup( + createElement(EvidencePopover, { rows: [row], onSource: () => {} }), + ); + + expect(markup).toContain("Invite Jev"); + expect(markup).toContain("@mina"); + expect(markup).toContain(" { + let row: EvidenceRow = { + optionId: "jev", + label: "Invite Jev", + origin: "planner", + supporters: [], + opposers: [], + items: [], + rationale: "Repository permissions require it.", + }; + let markup = renderToStaticMarkup( + createElement(EvidencePopover, { rows: [row], onSource: () => {} }), + ); + + expect(markup).toContain("Why Chopin suggested this: Repository permissions require it."); + expect(markup).not.toContain(" + {label} {handles.join(", ")} + {shown.map((handle, index) => ( + + ))} + {handles.length > 8 && ( + + )} + + ); +} + +export function EvidencePopover({ rows, onSource }: { + rows: EvidenceRow[]; + onSource: (destination: SourceDestination) => void; +}) { + return ( +
+ {rows.map(row => ( +
+

+ {row.label} +

+
+ {row.origin === "planner" && ( + + Planner suggested + + )} + + +
+ {row.origin === "planner" && row.rationale && ( +

+ Why Chopin suggested this: {row.rationale} +

+ )} + {row.origin === "planner" && row.source && row.optionId && ( +
+ + {row.source.author.kind === "member" + ? `@${row.source.author.handle}` + : "Planner"} + + + {row.source.quote} + + +
+ )} + {row.items.length > 0 && ( +
    + {row.items.map(item => { + let source = item.sources[0]; + return ( +
  • + + {item.kind === "reason" ? "Reason" : "Constraint"} + + + {item.text} + + {source && ( + + )} +
  • + ); + })} +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/conversation-plan/evidence.test.ts b/apps/web/src/conversation-plan/evidence.test.ts new file mode 100644 index 00000000..63efe069 --- /dev/null +++ b/apps/web/src/conversation-plan/evidence.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "bun:test"; + +import type { ConversationPlan, Question } from "@chopin/protocol"; + +import { evidenceRows, hasEvidence } from "./evidence"; + +let source = ( + messageId: string, + quote: string, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef => ({ + messageId, + author: { kind: "member", handle: "mina" }, + quote, + start: 0, + end: quote.length, + role, +}); + +let contribution = ( + id: string, + kind: ConversationPlan.Contribution["kind"], + text: string, + targetId?: string, +): ConversationPlan.Contribution => ({ + id, + kind, + text, + ...(targetId ? { targetId } : {}), + authoring: "quoted", + sources: [source(`m-${id}`, text, kind)], + actor: { kind: "member", handle: "mina" }, +}); + +let stance = ( + participant: string, + optionId: string | undefined, + position: ConversationPlan.Stance["position"], +): ConversationPlan.Stance => ({ + id: `${participant}-${optionId}-${position}`, + participant, + ...(optionId ? { optionId } : {}), + position, + sources: [], + at: 0, +}); + +const THREAD: ConversationPlan.Thread = { + id: "t1", + question: "What auth system should we use?", + questionSources: [], + questionAuthoring: "quoted", + status: "exploring", + contributions: [ + contribution("auth0", "option", "Auth0"), + contribution("github", "option", "GitHub Apps"), + contribution("r1", "reason", "People already have GitHub accounts.", "github"), + contribution("c1", "constraint", "Must support work organisations.", "t1"), + contribution("r2", "reason", "The team can maintain it."), + ], + stances: [ + stance("mina", "github", "support"), + stance("jules", "github", "support"), + stance("jules", "github", "support"), + stance("lee", "auth0", "oppose"), + stance("sam", "auth0", "neutral"), + stance("theo", undefined, "support"), + ], + stanceHistory: [stance("lee", "github", "oppose")], + decisionHistory: [], + candidates: [], + version: 3, +}; + +const META: Question.CardMeta = { + status: "open", + origin: "conversation", + involved: [], + history: [], + optionOrigins: { + auth0: { origin: "human", rationale: "This must not be shown." }, + github: { origin: "chat", rationale: "This must not be shown either." }, + sso: { origin: "planner", rationale: "Enterprise customers expect SAML." }, + }, + refining: false, + hasProse: false, + proseOrphaned: false, +}; + +describe("evidenceRows", () => { + it("groups current stances and targeted evidence by option", () => { + let rows = evidenceRows(THREAD, META); + expect(rows.map(row => row.label)).toEqual([ + "Auth0", + "GitHub Apps", + "What auth system should we use?", + ]); + let github = rows[1]!; + expect(github.supporters).toEqual(["mina", "jules"]); + expect(github.opposers).toEqual([]); + expect(github.items.map(item => item.text)).toEqual([ + "People already have GitHub accounts.", + ]); + expect(github.items[0]!.id).toBe("r1"); + expect(github.items[0]!.sources.map(item => item.messageId)).toEqual(["m-r1"]); + expect(rows[0]!.opposers).toEqual(["lee"]); + expect(rows[0]!.supporters).toEqual([]); + expect(rows[0]!.origin).toBe("human"); + expect(rows[0]!.rationale).toBeUndefined(); + expect(rows[2]!.items.map(item => item.text)).toEqual([ + "Must support work organisations.", + "The team can maintain it.", + ]); + }); + + it("places question-level evidence last and omits an empty general row", () => { + let rows = evidenceRows(THREAD, META); + let general = rows.at(-1)!; + expect(general.optionId).toBeUndefined(); + expect(general.items.map(item => item.kind)).toEqual(["constraint", "reason"]); + let bare = { + ...THREAD, + contributions: THREAD.contributions.filter(item => item.kind === "option"), + stances: [], + }; + expect(evidenceRows(bare, META).every(row => row.optionId !== undefined)).toBe(true); + }); + + it("follows card order and keeps omitted or unknown option items in the general row", () => { + let thread: ConversationPlan.Thread = { + ...THREAD, + contributions: [ + ...THREAD.contributions, + contribution("sso", "option", "Enterprise SSO"), + contribution("custom", "option", "Custom provider"), + contribution("r3", "reason", "This option was removed from the card.", "auth0"), + contribution("r4", "reason", "The old target is unknown.", "gone"), + ], + stances: [ + ...THREAD.stances, + stance("omitted", "auth0", "support"), + stance("unknown", "gone", "oppose"), + ], + }; + let rows = evidenceRows(thread, META, [ + { id: "sso", label: "Enterprise SSO" }, + { id: "github", label: "GitHub Apps" }, + { id: "custom", label: "Custom provider" }, + { id: "gone", label: "Removed option" }, + ]); + expect(rows.slice(0, 3).map(row => row.optionId)).toEqual(["sso", "github", "custom"]); + expect(rows[0]).toMatchObject({ + origin: "planner", + rationale: "Enterprise customers expect SAML.", + }); + expect(rows[1]!.rationale).toBeUndefined(); + expect(rows[2]!.origin).toBe("chat"); + let general = rows.at(-1)!; + expect(general.optionId).toBeUndefined(); + expect(general.items.map(item => item.text)).toEqual([ + "Must support work organisations.", + "The team can maintain it.", + "This option was removed from the card.", + "The old target is unknown.", + ]); + expect(general.supporters).toEqual([]); + expect(general.opposers).toEqual([]); + }); + + it("shows a sourced Planner option without a conversation contribution", () => { + let citation = source("m-jevi", "Jev needs access to the release plan.", "option"); + let meta: Question.CardMeta = { + ...META, + optionOrigins: { + jev: { origin: "planner", rationale: "Jev can review the plan.", source: citation }, + }, + }; + let thread = { ...THREAD, contributions: [], stances: [] }; + let rows = evidenceRows(thread, meta, [{ id: "jev", label: "Invite Jev" }]); + expect(rows).toMatchObject([{ + optionId: "jev", + label: "Invite Jev", + origin: "planner", + rationale: "Jev can review the plan.", + source: citation, + }]); + expect(hasEvidence(rows)).toBe(true); + }); + + it("uses one row when a sourced Planner option also has a conversation contribution", () => { + let citation = source("m-jevi", "Jev needs access to the release plan.", "option"); + let meta: Question.CardMeta = { + ...META, + optionOrigins: { + jev: { origin: "planner", rationale: "Jev can review the plan.", source: citation }, + }, + }; + let thread = { ...THREAD, contributions: [contribution("jev", "option", "Invite Jev")] }; + let rows = evidenceRows(thread, meta, [{ id: "jev", label: "Invite Jev" }]); + expect(rows).toHaveLength(1); + expect(rows[0]?.source).toEqual(citation); + }); + + it("keeps repository rationale separate from chat quotes", () => { + let meta: Question.CardMeta = { + ...META, + optionOrigins: { + jev: { origin: "planner", rationale: "Repository permissions require it." }, + }, + }; + let thread = { ...THREAD, contributions: [], stances: [] }; + let rows = evidenceRows(thread, meta, [{ id: "jev", label: "Invite Jev" }]); + expect(rows).toHaveLength(1); + expect(rows[0]?.rationale).toBe("Repository permissions require it."); + expect(rows[0]?.source).toBeUndefined(); + }); +}); + +describe("hasEvidence", () => { + it("only returns true when a row has visible evidence", () => { + let bare = { + ...THREAD, + contributions: THREAD.contributions.filter(item => item.kind === "option"), + stances: [], + }; + expect(hasEvidence(evidenceRows(bare, META))).toBe(false); + expect(hasEvidence(evidenceRows(THREAD, META))).toBe(true); + }); + + it("does not treat an empty Planner rationale as visible evidence", () => { + let plannerOption: ConversationPlan.Thread = { + ...THREAD, + contributions: [contribution("sso", "option", "Enterprise SSO")], + stances: [], + }; + let plannerMeta: Question.CardMeta = { + ...META, + optionOrigins: { sso: { origin: "planner", rationale: "Enterprise customers expect SAML." } }, + }; + let emptyRationaleMeta: Question.CardMeta = { + ...plannerMeta, + optionOrigins: { sso: { origin: "planner", rationale: "" } }, + }; + expect(hasEvidence(evidenceRows(plannerOption, plannerMeta))).toBe(true); + expect(hasEvidence(evidenceRows(plannerOption, emptyRationaleMeta))).toBe(false); + }); +}); diff --git a/apps/web/src/conversation-plan/evidence.ts b/apps/web/src/conversation-plan/evidence.ts new file mode 100644 index 00000000..a66e9467 --- /dev/null +++ b/apps/web/src/conversation-plan/evidence.ts @@ -0,0 +1,109 @@ +import type { ConversationPlan, Question } from "@chopin/protocol"; + +export type EvidenceItem = { + id: string; + kind: "reason" | "constraint"; + text: string; + sources: ConversationPlan.SourceRef[]; +}; + +export type EvidenceRow = { + /** Absent for evidence about the question as a whole. */ + optionId?: string; + label: string; + origin: "chat" | "planner" | "human"; + supporters: string[]; + opposers: string[]; + items: EvidenceItem[]; + rationale?: string; + source?: ConversationPlan.SourceRef; +}; + +export function evidenceRows( + thread: ConversationPlan.Thread, + meta: Question.CardMeta, + cardOptions?: Pick[], +): EvidenceRow[] { + let options = thread.contributions.filter(contribution => contribution.kind === "option"); + let optionById = new Map(); + for (let option of options) optionById.set(option.id, option); + let orderedOptions = cardOptions + ? cardOptions.flatMap(cardOption => { + let option = optionById.get(cardOption.id); + if (option) return [option]; + let origin = meta.optionOrigins[cardOption.id]; + return origin?.origin === "planner" && (origin.rationale || origin.source) + ? [{ id: cardOption.id, text: cardOption.label }] + : []; + }) + : options; + let rows: EvidenceRow[] = orderedOptions.map(option => { + let origin = meta.optionOrigins[option.id]?.origin ?? "chat"; + let rationale = origin === "planner" ? meta.optionOrigins[option.id]?.rationale : undefined; + let source = origin === "planner" ? meta.optionOrigins[option.id]?.source : undefined; + return { + optionId: option.id, + label: option.text, + origin, + supporters: [], + opposers: [], + items: [], + ...(rationale !== undefined ? { rationale } : {}), + ...(source ? { source } : {}), + }; + }); + let rowByOptionId = new Map(); + for (let row of rows) { + if (row.optionId) rowByOptionId.set(row.optionId, row); + } + + for (let stance of thread.stances) { + if (!stance.optionId) continue; + let row = rowByOptionId.get(stance.optionId); + if (!row) continue; + let handles = stance.position === "support" + ? row.supporters + : stance.position === "oppose" + ? row.opposers + : undefined; + if (handles && !handles.includes(stance.participant)) handles.push(stance.participant); + } + + let questionItems: EvidenceItem[] = []; + for (let contribution of thread.contributions) { + if (contribution.kind === "option") continue; + let item: EvidenceItem = { + id: contribution.id, + kind: contribution.kind, + text: contribution.text, + sources: contribution.sources, + }; + let row = contribution.targetId && contribution.targetId !== thread.id + ? rowByOptionId.get(contribution.targetId) + : undefined; + if (row) row.items.push(item); + else questionItems.push(item); + } + + if (questionItems.length) { + rows.push({ + label: thread.question, + origin: "chat", + supporters: [], + opposers: [], + items: questionItems, + }); + } + + return rows; +} + +export function hasEvidence(rows: EvidenceRow[]): boolean { + return rows.some(row => + row.supporters.length > 0 + || row.opposers.length > 0 + || row.items.length > 0 + || Boolean(row.rationale) + || Boolean(row.source) + ); +} diff --git a/apps/web/src/conversation-plan/excerpt-correction.tsx b/apps/web/src/conversation-plan/excerpt-correction.tsx new file mode 100644 index 00000000..646e91bd --- /dev/null +++ b/apps/web/src/conversation-plan/excerpt-correction.tsx @@ -0,0 +1,226 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { ExcerptCorrectionAction } from "./analysis-action"; + +export function ExcerptCorrection( + { canEdit, linked, linkedSubspans, messageId, messageText, onAddExcerpt, outcome, quote, state }: + { + canEdit: boolean; + linked: boolean; + linkedSubspans: ConversationPlan.SourceRef[]; + messageId: string; + messageText: string; + onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; + outcome: ConversationPlan.CandidateOutcome; + quote: string; + state?: ConversationPlan.State; + }, +) { + let [editing, setEditing] = useState(false); + let contributionType = useRef(null); + let [kind, setKind] = useState<"option" | "reason" | "constraint">("reason"); + let [threadId, setThreadId] = useState(""); + let [targetOptionId, setTargetOptionId] = useState(""); + let [exactText, setExactText] = useState(quote); + let [submitting, setSubmitting] = useState(false); + let [error, setError] = useState(""); + let [success, setSuccess] = useState(""); + let action = useRef<{ key: string; id: string } | undefined>(undefined); + let threads = + state?.threads.filter(thread => + thread.questionnaireId && !["decided", "discarded"].includes(thread.status) + ) ?? []; + let thread = threads.find(item => item.id === threadId); + let options = thread?.contributions.filter(item => item.kind === "option") ?? []; + let selectedText = exactText.trim(); + let sourceText = messageText.slice(outcome.start, outcome.end); + let localStart = selectedText ? sourceText.indexOf(selectedText) : -1; + let ambiguous = localStart >= 0 && sourceText.indexOf(selectedText, localStart + 1) >= 0; + let start = localStart < 0 ? -1 : outcome.start + localStart; + let end = start < 0 ? -1 : start + selectedText.length; + let alreadyAdded = linkedSubspans.some(source => source.start === start && source.end === end); + let validText = start >= outcome.start && end <= outcome.end && selectedText.length <= 500 + && !ambiguous && !alreadyAdded; + let validOption = !targetOptionId + || kind !== "option" && options.some(item => item.id === targetOptionId); + let ready = canEdit && !!onAddExcerpt && !!thread && validText && validOption && !submitting; + useLayoutEffect(() => { + if (editing) contributionType.current?.focus(); + }, [editing]); + let resetAction = () => { + action.current = undefined; + setError(""); + setSuccess(""); + }; + let submit = async () => { + if (!ready || !thread || !onAddExcerpt) return; + let key = JSON.stringify([ + thread.id, + thread.version, + messageId, + start, + end, + kind, + targetOptionId, + ]); + let currentAction = action.current; + if (!currentAction || currentAction.key !== key) { + currentAction = { key, id: crypto.randomUUID() }; + action.current = currentAction; + } + setSubmitting(true); + setError(""); + try { + await onAddExcerpt({ + actionId: currentAction.id, + threadId: thread.id, + expectedVersion: thread.version, + change: { + kind: "add-excerpt", + messageId, + start, + end, + contributionKind: kind, + ...(targetOptionId && kind !== "option" ? { targetOptionId } : {}), + }, + }); + setSuccess(`Added to ${thread.question} as a ${kind}.`); + setEditing(false); + } catch { + setError("Could not add this excerpt. Check the card and connection, then try again."); + } finally { + setSubmitting(false); + } + }; + + if (linked) { + return ( +

+ Added to this card. +

+ ); + } + if (!editing) { + return ( + <> + {(success || linkedSubspans.length > 0) && ( +

+ {success + || `Added ${linkedSubspans.length} excerpt${ + linkedSubspans.length === 1 ? "" : "s" + } to this card.`} +

+ )} + + + ); + } + + return ( +
+ + + {kind !== "option" && ( + + )} +