diff --git a/.env.example b/.env.example index 845f169c..c6466218 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ HARNESS=copilot-sdk BACKGROUND_JOBS=on WEB_RESEARCH=on +# Optional conversation-derived cards prototype; independent of AGENT. +CONVERSATION_PLAN=off +JEV_MODEL=jev-latest +# Per Jev request, in milliseconds (100–60000; default 30000). +JEV_TIMEOUT_MS=30000 +# Required only when CONVERSATION_PLAN=on. Keep this key on the server. +JEV_API_KEY= + # GitHub App sign-in. # Remote VM mode overrides this local origin in child processes; see docs/exe-dev.md. APP_ORIGIN=http://127.0.0.1:8787 diff --git a/AGENTS.md b/AGENTS.md index 00a1f940..ae5beefb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,9 +100,10 @@ external implementation runs are durable. - **The dialect is an allowlist.** Document MDX is parsed and rendered, never evaluated. Keep imports, exports, expressions, raw HTML, and unknown JSX out. - **Records own decisions.** Question answers and accepted comment decisions live - in sidecar records. Their document components are projections. Planner - operations protect those projections, but browser CRDT validation does not - yet cross-check them against records; do not treat the projection as authority. + in sidecar records. Their document components are projections. Browser CRDT + validation allows unchanged projections to move but rejects their creation, + removal or alteration. Domain operations update records and projections together; + do not treat the projection as authority. - **Admission is not authorization.** Optional user and organization lists admit an identity. Browser routes, sockets, and Planner tools separately recheck the App installation and repository role. @@ -139,13 +140,12 @@ replayed automatically because it may already have made durable tool changes. ## Questions, comments, and anchors -Question submission currently claims the shared draft with `claimSubmit()`, -projects the answer and authoritative record inside the room lock, calls -`Store.stage()`, persists the document or sidecar, and invokes the returned -finalizer. `stage()` removes the open draft before persistence, and the failure -path does not restore it after a storage error. Treat this as a known durability -gap: a proper two-phase refactor must retain or restore the draft until the -fenced commit succeeds. +Question submission claims the shared draft with `claimSubmit()`, then stages +the document, authoritative record, and copied question maps inside the room +lock. Publish the candidate only after its fenced commit and invoke the returned +`Store.stage()` finalizer afterward. A failed commit releases the claim and keeps +the accepted draft available for retry. Planner asks and cancellation follow the +same persistence-before-publication ordering. A decision's definition is frozen except for appended options. Any writer may send `question:option` to add one while the question is open (at most 10 options, @@ -160,8 +160,9 @@ renders and derives. Anchors combine Yjs relative positions with canonical block digests. A position survives surrounding edits; a digest can recover one unique block after a move or epoch replacement. Ambiguous matches must orphan rather than guess. The safe -ordering is to rebase against the old document before a server-authored edit; -the current Planner path reconciles first and is a known recovery gap. +ordering is to rebase against the old document before a server-authored edit. +Scoped decision-prose jobs follow that ordering. The ordinary `edit_plan` tool +still reconciles first and remains a recovery gap. The browser starts a comment from a bounded quote locator, selected length, offset hint, and block indices, not an unbounded copy of selected text. The @@ -279,9 +280,9 @@ so merge ranges from all mounted editors before replacing a registry entry. after `send()` loses edits on disconnect. - Rebuilds and reconnects must replay unacknowledged updates only when the epoch is still compatible. -- Idle eviction removes a room registry entry before its asynchronous close and - checkpoint completes. Until that lifecycle is serialized, avoid opening a - replacement room during close and test revision conflicts around eviction. +- Room opening and closing share the document lifecycle lock. Opening waits for + an active close; eviction forgets the room only after runtime shutdown and + persistence complete. Preserve that ordering. - Sidecar restoration currently drops an invalid optional implementation graph instead of rejecting the whole sidecar. Do not generalize that fail-open behavior to other durable fields. diff --git a/README.md b/README.md index 50f436a6..a7413c5f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ and tool vocabulary remain optimized for planning. credentials in an OS vault or an explicitly consented local file. Documents, transcripts, decisions, research request staging, background-job inputs and artifacts, and token-free session records are stored in PostgreSQL. +- Optional conversation-derived cards default to off. With `CONVERSATION_PLAN=on`, + current and recent Chat messages and selected decision context are sent to + TypeSafe's Jev service for interpretation. This uses the server's `JEV_API_KEY`, + separately from the Planner's harness credentials, and continues with `AGENT=off`. - One Chopin process may write to a database at a time. Horizontal application scaling and zero-downtime rolling deployment are not supported. diff --git a/apps/server/src/agent/card-tool-context.ts b/apps/server/src/agent/card-tool-context.ts new file mode 100644 index 00000000..2027b9db --- /dev/null +++ b/apps/server/src/agent/card-tool-context.ts @@ -0,0 +1,10 @@ +import { jobToolContext } from "./job-tool-context"; +import type { Context as JobContext } from "./job-tool-context"; +import type { DocumentRoom } from "./tools"; + +export type Context = JobContext & { currentMemberRequest?: () => { text: string } | undefined }; +export type CardToolRoom = DocumentRoom & Pick; + +export function cardToolContext(room: CardToolRoom): Context { + return { ...jobToolContext(room), currentMemberRequest: room.currentMemberRequest }; +} diff --git a/apps/server/src/agent/decision-prose-1.test.ts b/apps/server/src/agent/decision-prose-1.test.ts new file mode 100644 index 00000000..fd2afca7 --- /dev/null +++ b/apps/server/src/agent/decision-prose-1.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose commits one staged insert and publishes anchors and metadata afterward", async () => { + let context = await opened(); + job(context); + let tool = proseTool(context); + let before = room.project(context.plan.document); + let response = await tool.handler(input(context), {} as never); + expect(JSON.parse(String(response))).toEqual({ + ok: true, + title: "Which authentication approach?", + mode: "insert", + revision: 1, + }); + expect(room.project(context.plan.document)).toBe( + before.replace("Context.\n\n", "Context.\n\nWe chose GitHub Apps.\n\n"), + ); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "question:meta")).toHaveLength(1); + expect(tool.anchors()).toBe(1); + expect(tool.changes()).toBe(1); + expect(Questions.prose(context.plan)[0]).toMatchObject({ widget: CARD, orphaned: false }); + expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 1)) + .toBe(true); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document)); +}); + +test("write_decision_prose uses one durable delta for a first-card two-step insertion", async () => { + let context = await opened(WIDGET); + job(context); + let before = room.project(context.plan.document); + let result = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(room.project(context.plan.document)).toBe(`We chose GitHub Apps.\n\n${before}`); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 0)) + .toBe(true); +}); diff --git a/apps/server/src/agent/decision-prose-2.test.ts b/apps/server/src/agent/decision-prose-2.test.ts new file mode 100644 index 00000000..84d7a895 --- /dev/null +++ b/apps/server/src/agent/decision-prose-2.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, OTHER_WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose addresses a first card past live leading and inter-card carets", async () => { + let context = await opened(`${WIDGET}\n${OTHER_WIDGET}`); + context.plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore($createParagraphNode()); + children[1]!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + let caretKeys: string[] = []; + context.plan.document.editor.getEditorState().read(() => { + caretKeys = $getRoot().getChildren() + .filter(node => $isParagraphNode(node) && node.getChildrenSize() === 0) + .map(node => node.getKey()); + }); + expect(caretKeys).toHaveLength(2); + let relatives = caretKeys.map(key => { + let type = context.plan.document.binding.collabNodeMap.get(key)?.getSharedType(); + if (!type) throw new Error("caret has no shared identity"); + return Y.createRelativePositionFromTypeIndex(type, 0, -1); + }); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + expect(room.digests(context.plan.document)).toHaveLength(2); + expect(room.questionnaireIndex(context.plan.document, CARD)).toBe(0); + job(context); + let response = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(response)).ok).toBe(true); + expect(room.project(context.plan.document)).toStartWith( + "We chose GitHub Apps.\n\n { + let current = new Set($getRoot().getChildren().map(node => node.getKey())); + for (let key of caretKeys) expect(current.has(key)).toBe(true); + }); + let update = context.broadcasts.find(frame => frame.kind === "plan:update")?.update; + if (typeof update !== "string") throw new Error("missing persisted document delta"); + Y.applyUpdate(peer.doc, Buffer.from(update, "base64"), "remote"); + await room.settle(); + let absolutes = relatives.map(relative => + Y.createAbsolutePositionFromRelativePosition(relative, peer.doc, false) + ); + for (let absolute of absolutes) expect(absolute).not.toBeNull(); + peer.editor.getEditorState().read(() => { + let empty = $getRoot().getChildren().filter(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + ); + expect(empty).toHaveLength(2); + for (let absolute of absolutes) { + expect( + empty.some(node => + peer.binding.collabNodeMap.get(node.getKey())?.getSharedType() === absolute?.type + ), + ).toBe(true); + } + }); + peer.doc.destroy(); + let expected = room.project(context.plan.document); + await Service.close(context.plan); + contexts = contexts.filter(item => item !== context); + let reopened = await Service.open(context.channel.id, context.backend, context.server); + contexts.push({ ...context, plan: reopened }); + expect(room.project(reopened.document)).toBe(expected); + expect(room.matchesAnchor(reopened.document, reopened.records.get(CARD)!.prose![0]!, 0)) + .toBe(true); +}); + +test("write_decision_prose replaces the anchored duplicate, leaving its twin alone", async () => { + let context = await opened(`Same.\n\nSame.\n\n${WIDGET}`); + context.plan.records.get(CARD)!.prose = [ + room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!), + ]; + job(context); + let result = await proseTool(context).handler(input(context, "New prose."), {} as never); + expect(JSON.parse(String(result)).mode).toBe("replace"); + expect(room.project(context.plan.document)).toStartWith("Same.\n\nNew prose.\n\n"); + expect(context.plan.records.get(CARD)!.prose).toHaveLength(1); +}); diff --git a/apps/server/src/agent/decision-prose-3.test.ts b/apps/server/src/agent/decision-prose-3.test.ts new file mode 100644 index 00000000..9ab2bc45 --- /dev/null +++ b/apps/server/src/agent/decision-prose-3.test.ts @@ -0,0 +1,48 @@ +import { expect, spyOn, test } from "bun:test"; +import { $getRoot } from "lexical"; +import * as room from "../plan/room"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose replaces uniquely moved prose at its new position", async () => { + let context = await opened(`Other context.\n\nOld decision prose.\n\n${WIDGET}`); + context.plan.records.get(CARD)!.prose = [ + room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!), + ]; + context.plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore(children[1]!); + }, { discrete: true }); + await room.settle(); + job(context); + let result = await proseTool(context).handler(input(context, "New prose."), {} as never); + expect(JSON.parse(String(result)).mode).toBe("replace"); + expect(room.project(context.plan.document)).toStartWith("New prose.\n\nOther context.\n\n"); +}); + +test("write_decision_prose rejects missing or stale job generation and invalid text", async () => { + let context = await opened(); + let tool = proseTool(context); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await tool.handler(input(context), {} as never))) + .toContain("only available to a background Planner job"); + job(context, 2); + expect(String(await tool.handler(input(context), {} as never))).toContain("generation"); + job(context); + for (let text of ["# Heading", "One.\n\nTwo.", "Text "]) { + expect(String(await tool.handler(input(context, text), {} as never))).toStartWith("Error:"); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-4.test.ts b/apps/server/src/agent/decision-prose-4.test.ts new file mode 100644 index 00000000..d1a55392 --- /dev/null +++ b/apps/server/src/agent/decision-prose-4.test.ts @@ -0,0 +1,70 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, OPTION, opened, job, proseTool, input } = fixture; + +test("write_decision_prose fences a replaced job after an awaited staging read", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let original = room.restore; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let action = proseTool(context).handler(input(context), {} as never); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: "newer" } as ConversationPlan.Job; + release.resolve(); + expect(String(await action)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + held.mockRestore(); + } +}); + +test("write_decision_prose fences a newer saved decision after an awaited staging read", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let original = room.restore; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let action = proseTool(context).handler(input(context), {} as never); + await entered.promise; + let record = context.plan.records.get(CARD)!; + context.plan.records.set(CARD, { + ...record, + history: [{ choices: [OPTION], owner: "mina", at: 1_758_645_000 }], + }); + release.resolve(); + expect(String(await action)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + held.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-5.test.ts b/apps/server/src/agent/decision-prose-5.test.ts new file mode 100644 index 00000000..dc049f07 --- /dev/null +++ b/apps/server/src/agent/decision-prose-5.test.ts @@ -0,0 +1,56 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, opened, job, proseTool, input } = fixture; + +test("failed prose persistence leaves live source, record, and publication untouched", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let record = context.plan.records.get(CARD); + let records = context.plan.records; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await proseTool(context).handler(input(context), {} as never))) + .toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records).toBe(records); + expect(context.plan.records.get(CARD)).toBe(record); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0); + expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(0); +}); + +test("a post-commit anchor relay error does not turn the durable write into a failed tool call", async () => { + let context = await opened(); + job(context); + context.breakRelay("plan:anchors"); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let result = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(context.plan.chat.jobOutput).toBeDefined(); + expect(room.project(context.plan.document)).toContain("We chose GitHub Apps."); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document)); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-fields.ts b/apps/server/src/agent/decision-prose-fields.ts new file mode 100644 index 00000000..976c91b2 --- /dev/null +++ b/apps/server/src/agent/decision-prose-fields.ts @@ -0,0 +1,16 @@ +import { validateProse } from "../questions/write-prose"; + +export function input(raw: unknown): { revision: number; id: string; text: string } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("write_decision_prose arguments must be an object"); + } + let value = raw as Record; + if ( + Object.keys(value).sort().join(",") !== "id,revision,text" + || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 + || typeof value.id !== "string" || !value.id || value.id.length > 200 + ) throw new Error("write_decision_prose needs revision, id, and text"); + let valid = validateProse(value.text); + if (!valid.ok) throw new Error(valid.message); + return { revision: value.revision as number, id: value.id, text: valid.text }; +} diff --git a/apps/server/src/agent/decision-prose.test-fixtures.ts b/apps/server/src/agent/decision-prose.test-fixtures.ts new file mode 100644 index 00000000..94699653 --- /dev/null +++ b/apps/server/src/agent/decision-prose.test-fixtures.ts @@ -0,0 +1,112 @@ +import { afterEach } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; + +export type Opened = Awaited>; + +export function createDecisionProseFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + + const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; + + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const WIDGET = ` + + + +`; + + const OTHER_WIDGET = WIDGET + .replaceAll(CARD, "01K0N4X2M5R8T3VQ7YB6ZC4DEF") + .replaceAll(QUESTION, "01K0N4Y2M5R8T3VQ7YB6ZC4DEF") + .replaceAll(OPTION, "01K0N4Z2M5R8T3VQ7YB6ZC4DEF"); + + let contexts: Opened[] = []; + + async function opened(source = `Context.\n\n${WIDGET}`) { + let context = await openPlan(source); + contexts.push(context); + context.plan.records.set( + CARD, + Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + }), + ); + return context; + } + + function job(context: Opened, generation = 1) { + context.plan.chat.job = { + id: `prose:${CARD}:decided:${CARD}:${generation}`, + kind: "prose", + target: CARD, + trigger: `decided:${CARD}:${generation}`, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + } + + function proseTool(context: Opened, exclusive = Service.exclusive) { + let anchors = 0; + let changes = 0; + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() { + anchors++; + Service.anchors(context.plan, context.server, "test"); + }, + changes() { + changes++; + }, + }).find(item => item.name === "write_decision_prose"); + if (!found?.handler) throw new Error("write_decision_prose is missing"); + return { handler: found.handler, anchors: () => anchors, changes: () => changes }; + } + + function input(context: Opened, text = "We chose GitHub Apps.") { + return { revision: context.plan.revision, id: CARD, text }; + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { CARD, QUESTION, OPTION, WIDGET, OTHER_WIDGET, contexts, opened, job, proseTool, input }; +} diff --git a/apps/server/src/agent/decision-prose.ts b/apps/server/src/agent/decision-prose.ts new file mode 100644 index 00000000..d7944238 --- /dev/null +++ b/apps/server/src/agent/decision-prose.ts @@ -0,0 +1,104 @@ +import * as Y from "yjs"; +import * as Comments from "../comments/service"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import * as Questions from "../questions/service"; +import { proseOperation } from "../questions/write-prose"; +import { input } from "./decision-prose-fields"; +import type { Context } from "./card-tool-context"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function write( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ output: { title: string; mode: "insert" | "replace" }; revision: number }> { + return context.exclusive(async () => { + let { plan } = context; + let args = input(raw); + let active = () => { + let record = plan.records.get(args.id); + return plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && job.kind === "prose" && job.target === args.id + && record?.origin === "conversation" && record.status === "answered" + && !!record.owner && !!record.threadId + && job.trigger === proseJobTrigger(args.id, decisionGeneration(record)); + }; + if (job.target !== args.id) throw new Error("This job writes up a different decision."); + if (!active()) throw new Error("background Planner job generation is no longer current"); + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + + let source = room.project(plan.document); + let vector = Y.encodeStateVector(plan.document.doc); + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + // Resolve old relationships before server-authored source edits can move their blocks. + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let planned = proseOperation(candidate, args.id, args.text); + if (!planned.ok) throw new Error(planned.message); + for (let operations of planned.steps) { + let outcome = edit.apply(candidate, args.revision, operations); + if (!outcome.ok) { + throw new Error(outcome.reason === "stale" ? "stale; read_plan again" : outcome.message); + } + if (!outcome.mutation) throw new Error("decision prose did not change the document"); + } + Questions.rebase(candidate, source); + Comments.rebase(candidate); + let hash = room.digests(candidate.document)[planned.index]; + if (!hash) throw new Error("decision prose did not land in the document"); + Questions.setProse(candidate, args.id, [ + room.anchorAt(candidate.document, planned.index, hash), + ]); + if (!active() || args.revision !== plan.revision) { + throw new Error("background Planner job changed before its tool completed"); + } + let mutation: room.Mutation = { + update: Y.encodeStateAsUpdate(candidate.document.doc, vector), + source: room.project(candidate.document), + }; + let block = edit.outline(candidate)[planned.index]; + if (!block || block.type !== "paragraph") { + throw new Error("decision prose did not land as a paragraph"); + } + await Service.publishStaged(plan, context.server, context.room, candidate, mutation); + try { + context.changes([{ + kind: "added", + index: planned.index, + type: block.type, + preview: block.preview, + }]); + } catch (error) { + console.error("[agent/write_decision_prose] could not announce committed change:", error); + } + try { + context.anchors(); + } catch (error) { + console.error("[agent/write_decision_prose] could not announce committed anchors:", error); + } + let title = plan.records.get(args.id)?.definition.questions[0]?.question ?? "a decision"; + return { output: { title, mode: planned.mode }, revision: plan.revision }; + } finally { + stagedDocument.doc.destroy(); + } + }); +} diff --git a/apps/server/src/agent/heading-harness.test-fixtures.ts b/apps/server/src/agent/heading-harness.test-fixtures.ts new file mode 100644 index 00000000..cca5eb15 --- /dev/null +++ b/apps/server/src/agent/heading-harness.test-fixtures.ts @@ -0,0 +1,133 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import * as Service from "../plan/service"; + +import type { HarnessV1 } from "@ai-sdk/harness"; +import type { ActiveOwnerBinding } from "./active-owner"; +import type { DocumentRoom } from "./tools"; +import type { openPlan } from "../testing/plan"; + +export const INPUT = { revision: 0, title: "Sign in", goal: "Choose how people sign in." }; + +export function documentRoom(context: Awaited>): DocumentRoom { + return { + id: context.channel.id, + plan: context.plan, + server: context.server, + exclusive: action => Service.exclusive(context.plan, action), + persist: () => Service.persist(context.plan), + async publish() {}, + anchors() {}, + changes() {}, + }; +} + +export async function headingSession( + room: DocumentRoom, + submit: (result: unknown) => Promise, +) { + let names: string[] = []; + let destroyed = { session: 0, sandbox: 0 }; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "heading-test", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + names = options.tools.map(spec => spec.name); + let done = Promise.withResolvers(); + queueMicrotask(() => + options.emit({ + type: "tool-call", + toolCallId: "heading-1", + toolName: "draft_heading", + input: JSON.stringify(INPUT), + }) + ); + return { + done: done.promise, + async submitToolResult(value) { + await submit(value); + options.emit({ + type: "tool-result", + toolCallId: "heading-1", + toolName: "draft_heading", + result: value.output as string, + }); + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + done.resolve(); + }, + }; + }, + async doDestroy() { + destroyed.session++; + }, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + throw new Error("unused"); + }, + }; + }, + }; + let owner: ActiveOwnerBinding = { + channelId: room.id, + token: "test-token", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + ownerSessionId: "owner-session", + ownerGeneration: 1, + credentialRevision: 1, + expiresAt: new Date(Date.now() + 60_000), + signal: new AbortController().signal, + currentToken: () => "test-token", + revalidate: async () => true, + release() {}, + }; + let opened = await openPlannerSession(owner, { + room, + repository: owner.repository, + instructions: "Title the document only.", + }, { + headingAgent: createPlannerAgent(fake, "heading"), + githubTools: async () => ({ + ok: true, + value: { + list_pull_requests: tool({ inputSchema: z.object({}), execute: async () => "[]" }), + pull_request_read: tool({ inputSchema: z.object({}), execute: async () => "unused" }), + }, + }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() { + destroyed.sandbox++; + }, + }) as never, + registerCredential: () => () => {}, + }); + if (!opened.ok) throw new Error(`Could not open test Planner: ${opened.error.kind}`); + return { session: opened.value, owner, names: () => names, destroyed }; +} diff --git a/apps/server/src/agent/heading-harness.test.ts b/apps/server/src/agent/heading-harness.test.ts new file mode 100644 index 00000000..fcd86809 --- /dev/null +++ b/apps/server/src/agent/heading-harness.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { tool } from "ai"; +import { z } from "zod"; +import { openPlan } from "../testing/plan"; +import * as Service from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlannerSession } from "../harness/session"; +import { scopedJobTools } from "./job-tools"; +import { documentRoom, headingSession, INPUT } from "./heading-harness.test-fixtures"; + +function running(plan: Service.Plan) { + plan.chat.job = { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("current Harness executes heading against durable memory before returning its result", async () => { + let context = await openPlan(); + running(context.plan); + let observed = false; + let hosted = await headingSession(documentRoom(context), async result => { + expect(result).toMatchObject({ output: expect.stringContaining('"ok": true') }); + let source = `# ${INPUT.title}\n\n${INPUT.goal}\n`; + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + expect((await Service.readStored(loaded!)).source).toBe(source); + expect(Room.project(context.plan.document)).toBe(source); + expect(result).toMatchObject({ + output: JSON.stringify( + { ok: true, title: INPUT.title, goal: INPUT.goal, revision: 1 }, + null, + 2, + ), + }); + observed = true; + }); + try { + let stream = await hosted.session.stream( + "Give the empty document a heading", + hosted.owner.signal, + ); + for await (let _part of stream.fullStream) {} + expect(observed).toBe(true); + expect(context.plan.chat.jobOutput).toBe( + JSON.stringify({ title: INPUT.title, goal: INPUT.goal }), + ); + expect(hosted.names()).toContain("draft_heading"); + expect(hosted.names()).toContain("read_plan"); + for ( + let name of [ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "bash", + "read_file", + "web_search", + "refine_decision", + "write_decision_prose", + ] + ) expect(hosted.names()).not.toContain(name); + } finally { + await hosted.session.destroy(); + await Service.close(context.plan); + } + expect(hosted.destroyed).toEqual({ session: 1, sandbox: 1 }); +}); + +test("actual execution boundary refuses foreign writes even when a harness keeps old tools", async () => { + let context = await openPlan(); + let writes = 0; + let scoped = scopedJobTools({ + edit_plan: tool({ + inputSchema: z.object({}), + execute: async () => { + writes++; + return "written"; + }, + }), + }, documentRoom(context)); + try { + let options = { toolCallId: "old-tool", messages: [], context: undefined }; + expect(await scoped.edit_plan!.execute!({} as never, options)).toBe("written"); + running(context.plan); + expect(await scoped.edit_plan!.execute!({} as never, options)).toBe( + "Error: This is a background heading job; use only draft_heading. edit_plan is not available.", + ); + expect(writes).toBe(1); + expect(Room.project(context.plan.document)).toBe(""); + } finally { + await Service.close(context.plan); + } +}); + +test("unsupported background jobs cannot open an ordinary writable Planner session", async () => { + let context = await openPlan(); + running(context.plan); + context.plan.chat.job = { ...context.plan.chat.job!, kind: "unknown" as never, target: "thread" }; + let called = false; + try { + let opened = await openPlannerSession({} as never, { + room: documentRoom(context), + repository: {} as never, + instructions: "Unsupported background kind", + }, { + githubTools: async () => { + called = true; + throw new Error("must not fetch tools"); + }, + }); + expect(opened.ok).toBe(false); + if (opened.ok) throw new Error("unsupported job opened a session"); + expect(opened.error).toMatchObject({ kind: "Unavailable" }); + if (!("cause" in opened.error)) throw new Error("missing failure cause"); + expect((opened.error.cause as Error).message).toBe( + "Background unknown tools are not available", + ); + expect(called).toBe(false); + expect(Room.project(context.plan.document)).toBe(""); + } finally { + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/agent/heading-tool.test-bridge.ts b/apps/server/src/agent/heading-tool.test-bridge.ts new file mode 100644 index 00000000..d966e127 --- /dev/null +++ b/apps/server/src/agent/heading-tool.test-bridge.ts @@ -0,0 +1,18 @@ +import { jobTools } from "./job-tools"; +import type { DocumentRoom } from "./tools"; + +type ArchivedContext = Omit & { room: string }; + +/** The archived callbacks use the old handler shape; execute the current tool unchanged. */ +export function toolbox(context: ArchivedContext) { + let room = { ...context, id: context.room }; + return [{ + name: "draft_heading", + handler: (raw: unknown, _options: never) => + jobTools.draft_heading.execute!(raw as never, { + context: { room }, + toolCallId: "heading-test", + messages: [], + }), + }]; +} diff --git a/apps/server/src/agent/heading-tool.test.ts b/apps/server/src/agent/heading-tool.test.ts new file mode 100644 index 00000000..5143381a --- /dev/null +++ b/apps/server/src/agent/heading-tool.test.ts @@ -0,0 +1,226 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { QuestionnaireNode } from "@chopin/dialect"; +import { $nodesOfType } from "lexical"; + +import { toolbox } from "./heading-tool.test-bridge"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { ConversationPlan } from "@chopin/protocol"; + +const CARD = ` + + + +`; + +let contexts: Awaited>[] = []; + +afterEach(async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; +}); + +async function opened(source: string) { + let context = await openPlan(source); + contexts.push(context); + return context; +} + +function headingJob(plan: Awaited>) { + plan.chat.job = { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +function headingTool(context: Awaited>, exclusive = Service.exclusive) { + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + }).find(item => item.name === "draft_heading"); + if (!found?.handler) throw new Error("draft_heading is missing"); + return { handler: found.handler }; +} + +function cardKey(context: Awaited>): string { + let key = ""; + context.plan.document.editor.getEditorState().read(() => { + key = $nodesOfType(QuestionnaireNode)[0]?.getKey() ?? ""; + }); + return key; +} + +const INPUT = { + revision: 0, + title: "Authentication Strategy", + goal: "Goal: Decide how people sign in.", +}; + +test("draft_heading titles an empty document in one durable update", async () => { + let context = await opened(""); + headingJob(context.plan); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result))).toEqual({ + ok: true, + revision: 1, + title: INPUT.title, + goal: INPUT.goal, + }); + expect(room.project(context.plan.document)).toBe(`# ${INPUT.title}\n\n${INPUT.goal}\n`); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); +}); + +test("draft_heading commits one heading and paragraph before the existing card", async () => { + let context = await opened(CARD); + headingJob(context.plan); + let before = room.project(context.plan.document); + let key = cardKey(context); + let threads = context.plan.threads; + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result))).toEqual({ + ok: true, + revision: 1, + title: INPUT.title, + goal: INPUT.goal, + }); + let expected = `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before}`; + expect(room.project(context.plan.document)).toBe(expected); + expect(cardKey(context)).toBe(key); + expect(context.plan.threads).not.toBe(threads); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(JSON.parse(context.plan.chat.jobOutput!)).toEqual({ + title: INPUT.title, + goal: INPUT.goal, + }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(expected); +}); + +test("draft_heading replaces only the empty H1 template before cards", async () => { + let context = await opened(`# \n\n${CARD}`); + headingJob(context.plan); + let before = room.project(context.plan.document); + expect(before.startsWith("#\n\n")).toBe(true); + let key = cardKey(context); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(room.project(context.plan.document)).toBe( + `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before.slice("#\n\n".length)}`, + ); + expect(cardKey(context)).toBe(key); +}); + +test("draft_heading refuses human prose and leaves the document untouched", async () => { + for (let source of ["Somebody wrote this.\n", "# Human heading\n\nA human goal.\n"]) { + let context = await opened(source); + headingJob(context.plan); + let before = room.project(context.plan.document); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(String(result)).toBe( + "Error: The document already has prose. A background job only titles an empty document.", + ); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } +}); + +test("draft_heading rejects missing, wrong, stale, and replaced job scope", async () => { + let context = await opened(""); + let missing = await headingTool(context).handler(INPUT, {} as never); + expect(String(missing)).toContain("only available to a background Planner job"); + headingJob(context.plan); + context.plan.chat.job = { ...context.plan.chat.job!, target: "other" } as ConversationPlan.Job; + expect(String(await headingTool(context).handler(INPUT, {} as never))).toContain( + "heading job target is not the document", + ); + headingJob(context.plan); + expect(String(await headingTool(context).handler({ ...INPUT, revision: 99 }, {} as never))) + .toContain("stale; read_plan again"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let delayed = headingTool(context, (_plan, action) => { + entered.resolve(); + return release.promise.then(action); + }); + let result = delayed.handler(INPUT, {} as never); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: "heading:document:m1" }; + release.resolve(); + expect(String(await result)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(""); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); + +test("draft_heading rejects lengths, CR titles, blank lines, and non-paragraph goals", async () => { + let context = await opened(""); + headingJob(context.plan); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let input of [ + { ...INPUT, title: "x".repeat(81) }, + { ...INPUT, title: "First\rSecond" }, + { ...INPUT, title: "# Already a heading" }, + { ...INPUT, extra: "surprise" }, + { ...INPUT, goal: "x".repeat(401) }, + { ...INPUT, goal: "First\r\n\r\nSecond" }, + { ...INPUT, goal: "- a list item" }, + { ...INPUT, goal: "```ts\ncode\n```" }, + { ...INPUT, goal: "" }, + ] + ) { + expect(String(await headingTool(context).handler(input, {} as never))).toStartWith("Error:"); + expect(room.project(context.plan.document)).toBe(""); + } + } finally { + errors.mockRestore(); + } + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); + +test("failed heading persistence leaves no live edit, publication, or job output", async () => { + let context = await opened(CARD); + headingJob(context.plan); + let before = room.project(context.plan.document); + let outlines = new Map(context.plan.outlines); + let threads = context.plan.threads; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await headingTool(context).handler(INPUT, {} as never))) + .toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.revision).toBe(0); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.outlines).toEqual(outlines); + expect(context.plan.threads).toBe(threads); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(before); +}); diff --git a/apps/server/src/agent/heading-tool.ts b/apps/server/src/agent/heading-tool.ts new file mode 100644 index 00000000..2dbc2d8d --- /dev/null +++ b/apps/server/src/agent/heading-tool.ts @@ -0,0 +1,124 @@ +import * as Y from "yjs"; +import { parse } from "@chopin/dialect/parse"; +import { serialize } from "@chopin/dialect/serialize"; + +import * as Comments from "../comments/service"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Context } from "./job-tool-context"; + +type Input = { revision: number; title: string; goal: string }; + +function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("draft_heading arguments must be an object"); + } + let value = raw as Record; + if ( + Object.keys(value).length !== 3 + || !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "title") + || !Object.hasOwn(value, "goal") + ) { + throw new Error("draft_heading accepts only revision, title, and goal"); + } + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + let title = typeof value.title === "string" ? value.title.trim() : ""; + if ( + !title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string) + || title.startsWith("#") + ) { + throw new Error("title must be 1–80 characters on one line."); + } + let goal = typeof value.goal === "string" ? value.goal.trim() : ""; + if ( + !goal || goal.length > 400 + || /(?:\r\n|\r|\n)[ \t]*(?:\r\n|\r|\n)/.test(value.goal as string) + ) { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + let blocks; + try { + blocks = parse(`# ${title}\n\n${goal}\n`).children; + } catch { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + if ( + blocks.length !== 2 || blocks[0]?.type !== "heading" || blocks[0].depth !== 1 + || blocks[1]?.type !== "paragraph" + ) { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + return { revision: value.revision as number, title, goal }; +} + +/** Commit a heading and goal together, preserving every existing decision card. */ +export async function draftHeading( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ output: { title: string; goal: string }; revision: number }> { + return context.exclusive(async () => { + let { plan } = context; + let active = () => + plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && job.kind === "heading"; + if (!active()) throw new Error("background Planner job changed before its tool completed"); + if (job.target !== "document") throw new Error("heading job target is not the document"); + let { revision, title, goal } = input(raw); + if (revision !== plan.revision) throw new Error("stale; read_plan again"); + if (!room.headingAllowed(plan.document)) { + throw new Error( + "The document already has prose. A background job only titles an empty document.", + ); + } + + let source = room.project(plan.document); + let original = parse(source); + let placeholder = original.children[0]?.type === "heading" + && original.children[0].depth === 1 && original.children[0].children.length === 0; + let heading = parse(`# ${title}\n\n${goal}\n`); + let next = serialize({ + type: "root", + children: [...heading.children, ...original.children.slice(placeholder ? 1 : 0)], + }); + + // Resolve old anchors before the server edit moves any card in the live tree. + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let result = edit.replace(candidate, revision, next); + if (!result.ok) { + throw new Error(result.reason === "stale" ? "stale; read_plan again" : result.message); + } + if (!result.mutation) throw new Error("heading did not change the document"); + if (!active()) throw new Error("background Planner job changed before its tool completed"); + await Service.publishStaged(plan, context.server, context.room, candidate, result.mutation); + context.changes(result.changes); + context.anchors(); + return { output: { title, goal }, revision: plan.revision }; + } finally { + stagedDocument.doc.destroy(); + } + }); +} diff --git a/apps/server/src/agent/job-scope.test.ts b/apps/server/src/agent/job-scope.test.ts new file mode 100644 index 00000000..a72011f1 --- /dev/null +++ b/apps/server/src/agent/job-scope.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; + +import { create } from "../chat/service"; +import { JOB_TOOLS, refusal, runJobTool, WRITE_TOOLS } from "./job-scope"; + +import type { ConversationPlan } from "@chopin/protocol"; + +const JOB: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", +}; + +describe("background Planner job scope", () => { + test("ordinary turns may write normally but cannot call a job tool", () => { + expect(JOB_TOOLS).toEqual({ + heading: "draft_heading", + refine: "refine_decision", + suggest: "refine_decision", + prose: "write_decision_prose", + }); + expect(WRITE_TOOLS.has("create_research_workspace")).toBe(true); + expect(WRITE_TOOLS.has("revise_open_decision")).toBe(true); + expect(refusal(undefined, "read_plan")).toBeUndefined(); + expect(refusal(undefined, "edit_plan")).toBeUndefined(); + expect(refusal(undefined, "refine_decision")).toBe( + "refine_decision is only available to a background Planner job.", + ); + }); + + test("a job can read and use exactly its own writing tool", () => { + expect(refusal(JOB, "read_plan")).toBeUndefined(); + expect(refusal(JOB, "refine_decision")).toBeUndefined(); + for ( + let name of [ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "revise_open_decision", + "draft_heading", + "write_decision_prose", + ] + ) { + expect(refusal(JOB, name)).toBe( + `This is a background refine job; use only refine_decision. ${name} is not available.`, + ); + } + }); +}); + +describe("runJobTool", () => { + test("records one bounded JSON output for the current running job", async () => { + let chat = create(); + chat.job = { ...JOB }; + let current = chat.job; + let result = await runJobTool(chat, "refine_decision", async job => { + expect(job).toBe(current); + return { output: { added: 2 }, skipped: ["duplicate"] }; + }); + expect(result).toEqual({ ok: true, added: 2, skipped: ["duplicate"] }); + expect(chat.jobOutput).toBe(JSON.stringify({ added: 2 })); + }); + + test("refuses absent, inactive, foreign, and read-only tool calls before producing", async () => { + let chat = create(); + let calls = 0; + let produce = async () => { + calls++; + return { output: {} }; + }; + await expect(runJobTool(chat, "refine_decision", produce)).rejects.toThrow( + "only available to a background Planner job", + ); + chat.job = { ...JOB, status: "failed" }; + await expect(runJobTool(chat, "refine_decision", produce)).rejects.toThrow( + "running background Planner job", + ); + chat.job = { ...JOB }; + await expect(runJobTool(chat, "draft_heading", produce)).rejects.toThrow( + "use only refine_decision", + ); + await expect(runJobTool(chat, "read_plan", produce)).rejects.toThrow( + "only its own tool", + ); + expect(calls).toBe(0); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("an awaited tool cannot record success for a replacement job", async () => { + let chat = create(); + chat.job = { ...JOB }; + let gate = Promise.withResolvers(); + let operation = runJobTool(chat, "refine_decision", async () => { + await gate.promise; + return { output: { added: 1 } }; + }); + chat.job = { ...JOB, id: "refine:W2:m2", target: "W2", trigger: "m2" }; + gate.resolve(); + await expect(operation).rejects.toThrow("background Planner job changed"); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("an awaited tool cannot record success for a replacement turn", async () => { + let chat = create(); + chat.job = { ...JOB }; + chat.turn = { id: "turn-1", handle: "chopin", started: 1, responded: false }; + let gate = Promise.withResolvers(); + let operation = runJobTool(chat, "refine_decision", async () => { + await gate.promise; + return { output: { added: 1 } }; + }); + chat.turn = { id: "turn-2", handle: "chopin", started: 2, responded: false }; + gate.resolve(); + await expect(operation).rejects.toThrow("background Planner job changed"); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("rejects output that cannot be stored as a bounded JSON summary", async () => { + let chat = create(); + chat.job = { ...JOB }; + await expect(runJobTool(chat, "refine_decision", async () => ({ output: undefined }))) + .rejects.toThrow("valid JSON"); + await expect(runJobTool(chat, "refine_decision", async () => ({ + output: { detail: "x".repeat(4_096) }, + }))).rejects.toThrow("4096"); + expect(chat.jobOutput).toBeUndefined(); + }); +}); diff --git a/apps/server/src/agent/job-scope.ts b/apps/server/src/agent/job-scope.ts new file mode 100644 index 00000000..3e2bad56 --- /dev/null +++ b/apps/server/src/agent/job-scope.ts @@ -0,0 +1,71 @@ +/** A background Planner job may read freely but may write only through its own tool. */ + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Chat } from "../chat/service"; + +type Job = ConversationPlan.Job; + +export const JOB_TOOLS: Record = { + heading: "draft_heading", + refine: "refine_decision", + suggest: "refine_decision", + prose: "write_decision_prose", +}; + +export const WRITE_TOOLS: ReadonlySet = new Set([ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "revise_open_decision", + ...Object.values(JOB_TOOLS), +]); + +export function refusal(job: Job | undefined, tool: string): string | undefined { + let own = job ? JOB_TOOLS[job.kind] : undefined; + if (!job) { + return Object.values(JOB_TOOLS).includes(tool) + ? `${tool} is only available to a background Planner job.` + : undefined; + } + if (!WRITE_TOOLS.has(tool) || tool === own) return; + return `This is a background ${job.kind} job; use only ${own}. ${tool} is not available.`; +} + +export async function runJobTool( + chat: Chat, + tool: string, + produce: (job: Job) => Promise<{ output: unknown } & Record>, +): Promise { + let job = chat.job; + let refused = refusal(job, tool); + if (refused) throw new Error(refused); + if (!job || job.status !== "running") { + throw new Error("a running background Planner job is required"); + } + if (tool !== JOB_TOOLS[job.kind]) { + throw new Error("a background Planner job may run only its own tool"); + } + + let turn = chat.turn; + let id = job.id; + let { output, ...reply } = await produce(job); + if (chat.job !== job || job.id !== id || job.status !== "running" || chat.turn !== turn) { + throw new Error("background Planner job changed before its tool completed"); + } + let serialized: string | undefined; + try { + serialized = JSON.stringify(output); + } catch { + throw new Error("background Planner job output must be valid JSON"); + } + if (typeof serialized !== "string") { + throw new Error("background Planner job output must be valid JSON"); + } + if (serialized.length > 4_096) { + throw new Error("background Planner job output exceeds 4096 characters"); + } + chat.jobOutput = serialized; + return { ok: true, ...(output as object), ...reply }; +} diff --git a/apps/server/src/agent/job-tool-context.ts b/apps/server/src/agent/job-tool-context.ts new file mode 100644 index 00000000..c8fc46e7 --- /dev/null +++ b/apps/server/src/agent/job-tool-context.ts @@ -0,0 +1,18 @@ +import type { DocumentRoom } from "./tools"; + +export type Context = + & Pick + & { + room: string; + }; + +export function jobToolContext(room: DocumentRoom): Context { + return { + plan: room.plan, + server: room.server, + room: room.id, + exclusive: room.exclusive, + anchors: room.anchors, + changes: room.changes, + }; +} diff --git a/apps/server/src/agent/job-tools.ts b/apps/server/src/agent/job-tools.ts new file mode 100644 index 00000000..ff4ee653 --- /dev/null +++ b/apps/server/src/agent/job-tools.ts @@ -0,0 +1,66 @@ +import { jsonSchema, tool } from "ai"; +import { z } from "zod"; +import { draftHeading } from "./heading-tool"; +import { jobToolContext } from "./job-tool-context"; +import { refusal, runJobTool } from "./job-scope"; + +import type { ToolSet } from "ai"; +import type { DocumentRoom } from "./tools"; + +let contextSchema = z.object({ + room: z.custom(value => + !!value && typeof value === "object" + && typeof (value as DocumentRoom).id === "string" && !!(value as DocumentRoom).plan + ), +}); + +export let jobTools = { + draft_heading: tool({ + description: "Background job only. Give an empty document its title and one-paragraph " + + "goal, drawn from what people have said. Refused once the document has authored prose.", + inputSchema: jsonSchema<{ revision: number; title: string; goal: string }>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + title: { type: "string", minLength: 1, maxLength: 80 }, + goal: { type: "string", minLength: 1, maxLength: 400 }, + }, + required: ["revision", "title", "goal"], + additionalProperties: false, + }), + contextSchema, + execute: async (raw, { context: { room } }): Promise => { + try { + let turn = room.plan.chat.turn; + let result = await runJobTool( + room.plan.chat, + "draft_heading", + job => draftHeading(jobToolContext(room), job, turn, raw), + ); + return JSON.stringify(result, null, 2) ?? "null"; + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + console.error("[agent/draft_heading]", err); + return `Error: ${message}`; + } + }, + }), +}; + +/** Check the live job at execution, even if a harness ignores its active tool list. */ +export function scopedJobTools(tools: ToolSet, room: DocumentRoom): ToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, original]) => { + let execute = original.execute; + if (!execute) return [name, original]; + return [name, { + ...original, + execute: (input, options) => { + let denied = refusal(room.plan.chat?.job, name); + if (denied) return Promise.resolve(`Error: ${denied}`); + return execute(input, options); + }, + }]; + }), + ); +} diff --git a/apps/server/src/agent/refine-fields.ts b/apps/server/src/agent/refine-fields.ts new file mode 100644 index 00000000..19b8896e --- /dev/null +++ b/apps/server/src/agent/refine-fields.ts @@ -0,0 +1,96 @@ +import { assertSourceShape } from "../conversation-plan/sources"; +import type { ConversationPlan } from "@chopin/protocol"; + +export type Address = { index: number; digest: string }; + +export type Option = { label: string; rationale: string; source?: ConversationPlan.SourceRef }; + +export type Input = { + revision: number; + id: string; + title?: string; + add_options: Option[]; + place_after?: Address; +}; + +export function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("refine_decision arguments must be an object"); + } + let value = raw as Record; + if ( + !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "id") + || Object.keys(value).some(key => + !["revision", "id", "title", "add_options", "place_after"].includes(key) + ) + ) throw new Error("refine_decision arguments are invalid"); + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + if (typeof value.id !== "string" || !value.id) throw new Error("decision id is required"); + let title: string | undefined; + if (Object.hasOwn(value, "title")) { + title = typeof value.title === "string" ? value.title.trim() : ""; + if (!title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string)) { + throw new Error("title must be 1–80 characters on one line."); + } + } + let add_options: Option[] = []; + if (Object.hasOwn(value, "add_options")) { + if (!Array.isArray(value.add_options) || value.add_options.length > 10) { + throw new Error("add_options must contain at most ten options"); + } + for (let rawOption of value.add_options) { + if ( + !rawOption || typeof rawOption !== "object" || Array.isArray(rawOption) + || Object.keys(rawOption).some(key => + key !== "label" && key !== "rationale" && key !== "source" + ) + || !Object.hasOwn(rawOption, "label") || !Object.hasOwn(rawOption, "rationale") + ) throw new Error("each option needs a label and rationale"); + let option = rawOption as Record; + let label = typeof option.label === "string" ? option.label.trim() : ""; + let rationale = typeof option.rationale === "string" ? option.rationale.trim() : ""; + if (!label || label.length > 200) { + throw new Error("option label must be 1–200 characters"); + } + if (!rationale || rationale.length > 300) { + throw new Error("option rationale must be 1–300 characters"); + } + let source: ConversationPlan.SourceRef | undefined; + if (Object.hasOwn(option, "source")) { + try { + assertSourceShape(option.source); + } catch { + throw new Error("source-shape"); + } + source = option.source; + if (source.role !== "option" && source.role !== "question") { + throw new Error("option source must have option or question role"); + } + } + add_options.push({ label, rationale, ...(source ? { source } : {}) }); + } + } + let place_after: Address | undefined; + if (Object.hasOwn(value, "place_after")) { + let rawAddress = value.place_after; + if ( + !rawAddress || typeof rawAddress !== "object" || Array.isArray(rawAddress) + || Object.keys(rawAddress).sort().join(",") !== "digest,index" + ) throw new Error("place_after must identify one block"); + let address = rawAddress as Record; + if ( + !Number.isSafeInteger(address.index) || (address.index as number) < 0 + || typeof address.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(address.digest) + ) throw new Error("place_after must identify one block"); + place_after = { index: address.index as number, digest: address.digest }; + } + return { + revision: value.revision as number, + id: value.id, + ...(title ? { title } : {}), + add_options, + ...(place_after ? { place_after } : {}), + }; +} diff --git a/apps/server/src/agent/refine-placement.ts b/apps/server/src/agent/refine-placement.ts new file mode 100644 index 00000000..592a1b24 --- /dev/null +++ b/apps/server/src/agent/refine-placement.ts @@ -0,0 +1,43 @@ +import * as Y from "yjs"; +import * as Comments from "../comments/service"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import type { Context } from "./card-tool-context"; +import type { Address } from "./refine-fields"; + +export async function placeCard( + context: Context, + id: string, + at: Address, + beforePublish: () => void, +): Promise { + let { plan } = context; + let source = room.project(plan.document); + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let mutation = Questions.place(candidate, [{ widget: id, blocks: [at] }]); + if (!mutation) return false; + beforePublish(); + await Service.publishStaged(plan, context.server, context.room, candidate, mutation); + context.anchors(); + return true; + } finally { + stagedDocument.doc.destroy(); + } +} diff --git a/apps/server/src/agent/refine-tool-1.test.ts b/apps/server/src/agent/refine-tool-1.test.ts new file mode 100644 index 00000000..5b3f917c --- /dev/null +++ b/apps/server/src/agent/refine-tool-1.test.ts @@ -0,0 +1,129 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import type { Chat } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, source, linkQuestion } = fixture; + +test("refine_decision persists one question mention for two named alternatives", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-question", + author: { kind: "member", handle: "jev" }, + text: "Could we use Lexical or ProseMirror?", + ts: 1, + }; + context.plan.chat.entries.push(entry); + let ref = { ...source(entry, "Lexical or ProseMirror"), role: "question" as const }; + linkQuestion(context, ref); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Lexical", rationale: "Named in Jev's question.", source: ref }, + { label: "ProseMirror", rationale: "Named in Jev's question.", source: ref }, + ], + }), + ); + expect(answer.ok).toBe(true); + expect(answer.added).toHaveLength(2); + for (let id of answer.added) { + expect(context.plan.records.get(context.id)?.optionOrigins[id]?.source).toEqual(ref); + } + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + await expect(Service.readStored(loaded)).resolves.toBeDefined(); + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as { + questions: Questions.Record[]; + }; + sidecar.questions[0]!.optionOrigins[answer.added[0]]!.source = { + ...ref, + quote: "Lexical", + end: ref.start + 7, + }; + await expect(Service.readStored({ ...loaded, sidecar: sidecar as never })) + .rejects.toThrow(/option question source outside its card thread/); +}); + +test("refine_decision rejects question refs outside the card thread and other roles", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-question", + author: { kind: "member", handle: "jev" }, + text: "Could we use Lexical or ProseMirror?", + ts: 1, + }; + context.plan.chat.entries.push(entry); + let unrelated: Chat.Entry = { ...entry, id: "other-question", ts: 2 }; + context.plan.chat.entries.push(unrelated); + let ref = { ...source(entry, "Lexical or ProseMirror"), role: "question" as const }; + linkQuestion(context, ref); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let citation of [ + { ...ref, quote: "Lexical", end: ref.start + 7 }, + { ...ref, messageId: unrelated.id }, + { ...ref, author: { kind: "member", handle: "other" } }, + { ...ref, role: "reason" }, + { ...ref, role: "constraint" }, + ] + ) { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which editor should we use?", + add_options: [{ label: "Lexical", rationale: "Named in question.", source: citation }], + }); + expect(answer).toContain("Error"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + } + } finally { + errors.mockRestore(); + } +}); + +test("refine_decision persists a verified exact chat source and restores it", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-a", + author: { kind: "member", handle: "jev" }, + text: "We could use Lexical for selection.", + ts: 1, + }; + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let citation = source(entry, "Lexical"); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "Lexical", rationale: "Jev proposed it.", source: citation }], + }), + ); + expect(answer.ok).toBe(true); + let origin = context.plan.records.get(context.id)?.optionOrigins[answer.added[0]]; + expect(origin).toEqual({ origin: "planner", rationale: "Jev proposed it.", source: citation }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect( + (sidecar as { questions: Questions.Record[] }).questions[0]?.optionOrigins[answer.added[0]], + ) + .toEqual(origin!); + await expect(Service.readStored(loaded)).resolves.toBeDefined(); + let damaged = structuredClone(sidecar) as { questions: Questions.Record[] }; + damaged.questions[0]!.optionOrigins[answer.added[0]]!.source!.quote = "TipTaps"; + await expect(Service.readStored({ ...loaded, sidecar: damaged as never })) + .rejects.toThrow(/invalid option source/); +}); diff --git a/apps/server/src/agent/refine-tool-2.test.ts b/apps/server/src/agent/refine-tool-2.test.ts new file mode 100644 index 00000000..32762597 --- /dev/null +++ b/apps/server/src/agent/refine-tool-2.test.ts @@ -0,0 +1,36 @@ +import { test } from "bun:test"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { expectExplicitSourceNotAuthoritative } = fixture; + +test("refine_decision rejects an explicit unrelated member quote as an option source", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "unrelated-option-source", + author: { kind: "member", handle: "jev" }, + text: "We should use server-side caching for this endpoint.", + ts: 1, + }, "server-side caching"); +}); + +test("refine_decision rejects an explicit agent-authored option source", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "agent-option-source", + author: { kind: "agent" }, + text: "Tiptap is a good fit for the editor.", + ts: 1, + }, "Tiptap"); +}); + +test("refine_decision rejects an explicit quote that bundles same-approach choices", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "paired-option-source", + author: { kind: "member", handle: "jev" }, + text: "Tiptap or Lexical would work for the editor.", + ts: 1, + }, "Tiptap or Lexical"); +}); diff --git a/apps/server/src/agent/refine-tool-3.test.ts b/apps/server/src/agent/refine-tool-3.test.ts new file mode 100644 index 00000000..d29e0c35 --- /dev/null +++ b/apps/server/src/agent/refine-tool-3.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import * as Service from "../plan/service"; +import { effectsFor } from "../conversation-plan/effects"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, d01Messages } = fixture; + +test("refine_decision sources unique D01 options from the saved member list", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + let candidates = [ + { label: "Tiptap", quote: "Tiptap", start: 0, end: 6 }, + { label: "bare ProseMirror", quote: "bare ProseMirror", start: 8, end: 24 }, + { label: "Lexical", quote: "Lexical", start: 26, end: 33 }, + { + label: "just native Selection and Range with our own document model", + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + }, + ] as const; + job(context.plan, context.id); + let add_options = candidates.map(({ label }) => ({ + label, + rationale: "Rob listed this editor approach in m2.", + })); + let response = await call(context, { + revision: context.plan.revision, + id: context.id, + add_options, + }); + expect(response).not.toContain("Error:"); + let answer = JSON.parse(response); + let ids: string[] = answer.added; + let expectedSources: ConversationPlan.SourceRef[] = candidates.map(candidate => ({ + messageId: m2.id, + author: m2.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option" as const, + })); + expect(ids).toHaveLength(candidates.length); + let optionEvents = context.plan.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect(optionEvents.map(event => event.type === "option.added" && event.contribution.id)) + .toEqual(ids); + expect(optionEvents.map(event => event.type === "option.added" ? event.source : undefined)) + .toEqual(expectedSources); + + let retry = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options, + }), + ); + expect(retry.added).toEqual([]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => option.id), + ).toEqual(ids); + optionEvents = context.plan.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect(optionEvents.map(event => event.type === "option.added" && event.contribution.id)) + .toEqual(ids); + + await Service.close(context.plan); + contexts = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + contexts.push({ ...context, plan: restored }); + let restoredRecord = restored.records.get(context.id)!; + expect(restoredRecord.definition.questions[0]?.options.map(option => option.id)).toEqual(ids); + let restoredOptionEvents = restored.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect( + restoredOptionEvents.map(event => + event.type === "option.added" ? event.contribution.id : undefined + ), + ).toEqual(ids); + expect( + restoredOptionEvents.map(event => event.type === "option.added" ? event.source : undefined), + ).toEqual(expectedSources); + expect( + effectsFor(restoredOptionEvents, restored.conversationPlan, undefined, restored.records), + ).toEqual([]); + expect(ids.map(id => restoredRecord.optionOrigins[id]?.source)).toEqual(expectedSources); +}); + +test("refine_decision matches the D01 native option without its leading chat filler", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ + label: "Native Selection and Range with our own document model", + rationale: "Rob named this browser-native editor approach in m2.", + }], + }), + ); + let expected: ConversationPlan.SourceRef = { + messageId: m2.id, + author: m2.author as ConversationPlan.SourceAuthor, + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + role: "option", + }; + expect(answer.added).toHaveLength(1); + let optionId = answer.added[0] as string; + let optionEvent = context.plan.conversationPlan.events.find(event => + event.type === "option.added" && event.contribution.id === optionId + ); + expect(optionEvent?.type === "option.added" ? optionEvent.source : undefined).toEqual(expected); + expect(context.plan.records.get(context.id)?.optionOrigins[optionId]?.source).toEqual(expected); +}); + +test("refine_decision keeps Just Eat, unrelated, bundled, and ambiguous evidence distinct", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + context.plan.chat.entries.push({ + id: "just-eat", + author: { kind: "member", handle: "Rob" }, + text: "Just Eat", + ts: 3, + }); + context.plan.chat.entries.push({ ...m2, id: "d01-m2-copy", ts: m2.ts + 1 }); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Eat", rationale: "A distinct product name." }, + { label: "Trix", rationale: "An unrelated product." }, + { label: "Tiptap or Lexical", rationale: "Two bundled editor choices." }, + { + label: "Native Selection and Range with our own document model", + rationale: "The duplicate evidence is ambiguous.", + }, + ], + }), + ); + let record = context.plan.records.get(context.id)!; + expect(answer.added).toHaveLength(3); + expect(answer.skipped).toEqual(["Tiptap or Lexical"]); + for (let id of answer.added as string[]) { + expect(record.optionOrigins[id]?.source).toBeUndefined(); + } +}); diff --git a/apps/server/src/agent/refine-tool-4.test.ts b/apps/server/src/agent/refine-tool-4.test.ts new file mode 100644 index 00000000..55743eb8 --- /dev/null +++ b/apps/server/src/agent/refine-tool-4.test.ts @@ -0,0 +1,109 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import type { Chat } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, source, d01Messages } = fixture; + +test("refine_decision leaves ambiguous or non-member option evidence unattributed", async () => { + for (let evidence of ["ambiguous", "foreign"] as const) { + let context = await opened(); + let { m2 } = d01Messages(context); + if (evidence === "ambiguous") { + context.plan.chat.entries.push({ ...m2, id: "d01-m2-copy", ts: m2.ts + 1 }); + } else { + m2.author = { kind: "agent" }; + } + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Tiptap", rationale: "A possible editor approach." }, + { label: "bare ProseMirror", rationale: "A possible editor approach." }, + ], + }), + ); + let record = context.plan.records.get(context.id)!; + expect(answer.added).toHaveLength(2); + for (let id of answer.added) { + expect(record.optionOrigins[id]?.source).toBeUndefined(); + } + } +}); + +test("refine_decision rejects mismatched, foreign, and missing chat sources before edits", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-a", + author: { kind: "member", handle: "jev" }, + text: "We could use Lexical for selection.", + ts: 1, + }; + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let good = source(entry, "Lexical"); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let citation of [ + { ...good, quote: "TipTaps" }, + { ...good, author: { kind: "member", handle: "other" } }, + { ...good, messageId: "other-room-message" }, + ] + ) { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which editor should we use?", + add_options: [{ label: "Lexical", rationale: "Jev proposed it.", source: citation }], + }); + expect(answer).toContain("source"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + } + } finally { + errors.mockRestore(); + } +}); + +test("malformed question-source bounds reject before title, placement, or option edits", async () => { + let context = await opened(); + let { ref } = d01Messages(context); + job(context.plan, context.id); + let digest = room.digests(context.plan.document)[0]!; + let beforeDocument = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeSequence = context.plan.document.seq; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which rich-text editor should we use?", + add_options: [{ + label: "Tiptap", + rationale: "Rob listed this library in m2.", + source: { ...ref, end: ref.end + 1 }, + }], + place_after: { index: 0, digest }, + }); + + expect(answer).toBe("Error: source-shape"); + expect(answer).not.toContain("invalid conversation source"); + expect(room.project(context.plan.document)).toBe(beforeDocument); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.document.seq).toBe(beforeSequence); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/refine-tool-5.test.ts b/apps/server/src/agent/refine-tool-5.test.ts new file mode 100644 index 00000000..0a0417b5 --- /dev/null +++ b/apps/server/src/agent/refine-tool-5.test.ts @@ -0,0 +1,107 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Store from "../questions/store"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call, d01Messages } = fixture; + +test("a valid D01 m1 question source cannot source Rob's m2 editor options", async () => { + let context = await opened(); + let { m2, ref } = d01Messages(context); + job(context.plan, context.id); + let beforeDocument = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which rich-text editor should we use?", + add_options: [{ + label: "Tiptap", + rationale: `Rob proposed it in m2: ${m2.text}`, + source: ref, + }], + }); + + expect(answer).toContain("option question source does not name this card option"); + expect(answer).not.toContain("invalid conversation source"); + expect(answer).not.toContain("source-shape"); + expect(room.project(context.plan.document)).toBe(beforeDocument); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + } finally { + errors.mockRestore(); + } +}); + +test("refine_decision retitles, adds a reasoned option, and durably places the card", async () => { + let context = await opened([{ id: OPTION, label: "Anchors" }]); + job(context.plan, context.id); + let digest = room.digests(context.plan.document)[0]!; + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [{ label: "GitHub Apps", rationale: "Fits repository permissions." }], + place_after: { index: 0, digest }, + }), + ); + expect(answer).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + placed: true, + skipped: [], + full: [], + }); + expect(answer.added).toHaveLength(1); + let record = context.plan.records.get(context.id)!; + expect(record.definition.questions[0]?.question).toBe("Which auth system should ship first?"); + expect(record.optionOrigins[answer.added[0]]).toEqual({ + origin: "planner", + rationale: "Fits repository permissions.", + }); + let source = room.project(context.plan.document); + expect(source.indexOf("Opening prose.")).toBeLessThan(source.indexOf(" { + let options = Array.from({ length: 9 }, (_, index) => ({ + id: `01K0N4W3B7P27CBAEC7A8C8W${String(index).padStart(2, "0")}`, + label: index === 0 ? "Anchors" : `Option ${index}`, + })); + let context = await opened(options); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: " anchors ", rationale: "Already discussed." }, + { label: "Export", rationale: "Frequently requested." }, + { label: "Import", rationale: "Could follow export." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual(["anchors"]); + expect(answer.full).toEqual(["Import"]); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toHaveLength(10); +}); diff --git a/apps/server/src/agent/refine-tool-6.test.ts b/apps/server/src/agent/refine-tool-6.test.ts new file mode 100644 index 00000000..1d5a16d1 --- /dev/null +++ b/apps/server/src/agent/refine-tool-6.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call } = fixture; + +test("refine_decision keeps library alternatives atomic and skips reworded repeats", async () => { + let context = await opened([{ id: OPTION, label: "Tiptap" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Use Tiptap for string highlighting", rationale: "The team discussed it." }, + { label: "ProseMirror", rationale: "It is a distinct editor approach." }, + { label: "Lexical", rationale: "It is another editor approach." }, + { label: "Tiptap or ProseMirror or Lexical", rationale: "The team mentioned all three." }, + { + label: "Browser-native Selection and Range APIs", + rationale: "The native platform approach is available.", + }, + { label: "Selection/Range APIs", rationale: "Restates the native approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(3); + expect(answer.skipped).toEqual([ + "Use Tiptap for string highlighting", + "Tiptap or ProseMirror or Lexical", + "Selection/Range APIs", + ]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["Tiptap", "ProseMirror", "Lexical", "Browser-native Selection and Range APIs"]); +}); + +test("refine_decision rejects a bundled sibling choice without losing distinct options", async () => { + let context = await opened([{ id: OPTION, label: "SQLite" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "PostgreSQL", rationale: "The existing deployment uses it." }, + { label: "Use PostgreSQL for storage", rationale: "Restates PostgreSQL." }, + { label: "SQLite, PostgreSQL, and MySQL", rationale: "A grouped list." }, + { label: "MySQL", rationale: "It is another database approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(2); + expect(answer.skipped).toEqual(["Use PostgreSQL for storage", "SQLite, PostgreSQL, and MySQL"]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["SQLite", "PostgreSQL", "MySQL"]); +}); + +test("refine_decision skips slash lists on an empty card but keeps a paired API approach", async () => { + let context = await opened(); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Tiptap / ProseMirror / Lexical", rationale: "A bundled library list." }, + { label: "SQLite/PostgreSQL/MySQL", rationale: "A bundled database list." }, + { label: "Tiptap / ProseMirror", rationale: "Two separate libraries." }, + { label: "ProseMirror API / Lexical API", rationale: "Two separate API options." }, + { label: "Selection/Range APIs", rationale: "One browser-native approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual([ + "Tiptap / ProseMirror / Lexical", + "SQLite/PostgreSQL/MySQL", + "Tiptap / ProseMirror", + "ProseMirror API / Lexical API", + ]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["Selection/Range APIs"]); +}); diff --git a/apps/server/src/agent/refine-tool-7.test.ts b/apps/server/src/agent/refine-tool-7.test.ts new file mode 100644 index 00000000..8a28f967 --- /dev/null +++ b/apps/server/src/agent/refine-tool-7.test.ts @@ -0,0 +1,80 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call } = fixture; + +test("refine_decision preserves a distinct extended product name", async () => { + let context = await opened([{ id: OPTION, label: "React" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "React Native", rationale: "A mobile framework is a separate approach." }, + { label: "Use React for the browser", rationale: "Restates the existing option." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual(["Use React for the browser"]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["React", "React Native"]); +}); + +test("refine_decision refuses another target, stale revision, and closed records", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + job(context.plan, context.id); + let before = room.project(context.plan.document); + expect(await call(context, { revision: context.plan.revision, id: OPTION })) + .toContain("different decision"); + expect(await call(context, { revision: context.plan.revision + 1, id: context.id })) + .toContain("read_plan again"); + context.plan.records.delete(context.id); + expect(await call(context, { revision: context.plan.revision, id: context.id })) + .toContain("no longer open"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + } +}); + +test("a queued refine tool cannot write after its background job is replaced", async () => { + let context = await opened(); + job(context.plan, context.id); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let delayed = call(context, { + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }, (_plan, action) => { + entered.resolve(); + return release.promise.then(action); + }); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: `refine:${context.id}:m2` }; + release.resolve(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await delayed).toContain("background Planner job changed"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); diff --git a/apps/server/src/agent/refine-tool-8.test.ts b/apps/server/src/agent/refine-tool-8.test.ts new file mode 100644 index 00000000..c5364fbb --- /dev/null +++ b/apps/server/src/agent/refine-tool-8.test.ts @@ -0,0 +1,116 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call } = fixture; + +test("a job replaced during staged restoration cannot publish placement, title, or options", async () => { + for ( + let change of [ + { place_after: true }, + { title: "Why now?" }, + { add_options: [{ label: "GitHub Apps", rationale: "Uses repository access." }] }, + ] + ) { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let original = room.restore; + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let pending = call(context, { + revision: context.plan.revision, + id: context.id, + ...(change.place_after + ? { place_after: { index: 0, digest: room.digests(context.plan.document)[0] } } + : change), + }); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: `refine:${context.id}:m2` }; + release.resolve(); + expect(await pending).toContain("background Planner job changed"); + } finally { + errors.mockRestore(); + held.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } +}); + +test("suggest jobs reject both title and placement before changing the card", async () => { + let context = await opened(); + job(context.plan, context.id, "suggest"); + let before = room.project(context.plan.document); + let digest = room.digests(context.plan.document)[0]!; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Why?", + }), + ).toContain("refine job"); + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + place_after: { index: 0, digest }, + }), + ).toContain("refine job"); + expect(room.project(context.plan.document)).toBe(before); + } finally { + errors.mockRestore(); + } +}); + +test("invalid title, options, and placement are refused before any retitle commits", async () => { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, context.id)?.revision; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let invalid of [ + { title: "First\rSecond" }, + { title: "x".repeat(81) }, + { title: "Why?", add_options: [{ label: " ", rationale: "Valid" }] }, + { title: "Why?", add_options: [{ label: "Valid", rationale: " " }] }, + { title: "Why?", place_after: { index: 0, digest: room.digest("changed") } }, + ] + ) { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + ...invalid, + }), + ).toStartWith("Error:"); + expect(room.project(context.plan.document)).toBe(before); + expect(Store.get(context.plan.questions, context.id)?.revision).toBe(revision); + } + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/refine-tool-9.test.ts b/apps/server/src/agent/refine-tool-9.test.ts new file mode 100644 index 00000000..5b8195d5 --- /dev/null +++ b/apps/server/src/agent/refine-tool-9.test.ts @@ -0,0 +1,38 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call } = fixture; + +test("failed placement persistence leaves no live move or publication", async () => { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + place_after: { index: 0, digest: room.digests(context.plan.document)[0] }, + }), + ).toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); diff --git a/apps/server/src/agent/refine-tool.test-fixtures.ts b/apps/server/src/agent/refine-tool.test-fixtures.ts new file mode 100644 index 00000000..7555bcaf --- /dev/null +++ b/apps/server/src/agent/refine-tool.test-fixtures.ts @@ -0,0 +1,188 @@ +import { afterEach, expect } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import { applyEvent } from "../conversation-plan/events"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +function job( + plan: Awaited>, + id: string, + kind: "refine" | "suggest" = "refine", +) { + plan.chat.job = { + id: `${kind}:${id}:m1`, + kind, + target: id, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +export function createRefineToolFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + let contexts: Awaited>[] = []; + + async function opened(options: Array<{ id: string; label: string }> = []) { + let context = await openPlan("Opening prose.\n\nLater prose.\n"); + contexts.push(context); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }); + return { ...context, id }; + } + + function tool( + context: Awaited>, + exclusive = Service.exclusive, + ) { + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + }).find(item => item.name === "refine_decision"); + if (!found?.handler) throw new Error("refine_decision is missing"); + return found.handler; + } + + async function call( + context: Awaited>, + input: Record, + exclusive = Service.exclusive, + ) { + return String(await tool(context, exclusive)(input, {} as never)); + } + + function source(entry: Chat.Entry, quote: string): ConversationPlan.SourceRef { + let start = entry.text.indexOf(quote); + if (start < 0) throw new Error("quote missing from test message"); + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote, + start, + end: start + quote.length, + role: "option", + }; + } + + function linkQuestion( + context: Awaited> & { id: string }, + ref: ConversationPlan.SourceRef, + ) { + let opened = applyEvent(context.plan.conversationPlan, { + id: "open-question", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: ref, + question: "Lexical or ProseMirror?", + }); + context.plan.conversationPlan = applyEvent(opened, { + id: "link-question", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: context.id, + }); + } + + function d01Messages(context: Awaited> & { id: string }) { + let m1: Chat.Entry = { + id: "d01-m1", + author: { kind: "member", handle: "Nia" }, + text: "need to pick an editor before comments get any deeper. what are we comparing?", + ts: 1, + }; + let m2: Chat.Entry = { + id: "d01-m2", + author: { kind: "member", handle: "Rob" }, + text: + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + ts: 2, + }; + context.plan.chat.entries.push(m1, m2); + let ref = { ...source(m1, m1.text), role: "question" as const }; + linkQuestion(context, ref); + return { m1, m2, ref }; + } + + async function expectExplicitSourceNotAuthoritative(entry: Chat.Entry, quote: string) { + let context = await opened(); + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let citation = source(entry, quote); + let before = room.project(context.plan.document); + let response = await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ + label: "Tiptap", + rationale: "Candidate source for the Tiptap option.", + source: citation, + }], + }); + let record = context.plan.records.get(context.id)!; + if (response.includes("Error")) { + expect(room.project(context.plan.document)).toBe(before); + expect(record.definition.questions[0]?.options).toEqual([]); + return; + } + + let answer = JSON.parse(response) as { added: string[] }; + expect(answer.added).toHaveLength(1); + let id = answer.added[0]!; + expect(record.optionOrigins[id]?.source).toBeUndefined(); + let event = context.plan.conversationPlan.events.find(event => + event.type === "option.added" && event.contribution.id === id + ); + expect(event?.type === "option.added" ? event.source : undefined).toBeUndefined(); + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { + OPTION, + contexts, + opened, + job, + tool, + call, + source, + linkQuestion, + d01Messages, + expectExplicitSourceNotAuthoritative, + }; +} diff --git a/apps/server/src/agent/refine-tool.ts b/apps/server/src/agent/refine-tool.ts new file mode 100644 index 00000000..4150c73e --- /dev/null +++ b/apps/server/src/agent/refine-tool.ts @@ -0,0 +1,181 @@ +import { parse } from "@chopin/dialect/parse"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { matchesQuestionSource, questionMentionsOption } from "../questions/records"; +import { atomicOptionQuote, grouped, sameApproach } from "../questions/option-match"; +import { validateSource } from "../conversation-plan/sources"; +import { extractQuotes } from "../conversation-plan/quotes"; +import { input, type Option } from "./refine-fields"; +import { placeCard } from "./refine-placement"; +import type { Context } from "./card-tool-context"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function refineDecision( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ + output: { title: string; added: number }; + title: string; + added: string[]; + skipped: string[]; + full: string[]; + placed: boolean; +}> { + return context.exclusive(async () => { + let { plan } = context; + let active = () => + plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && (job.kind === "refine" || job.kind === "suggest"); + let ensureActive = () => { + if (!active()) throw new Error("background Planner job changed before its tool completed"); + }; + ensureActive(); + let args = input(raw); + if (args.id !== job.target) throw new Error("This job refines a different decision."); + if (job.kind === "suggest" && (args.title !== undefined || args.place_after !== undefined)) { + throw new Error("title and place_after are only changed by a refine job."); + } + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(args.id); + let live = Store.get(plan.questions, args.id); + if ( + !record || !Questions.isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + ) throw new Error("That decision is no longer open."); + let question = record.definition.questions[0]!; + if (live.definition.questions[0]?.id !== question.id) { + throw new Error("That decision is no longer open."); + } + if (!room.hasQuestionnaire(plan.document, args.id, question.id)) { + throw new Error("Card is not available"); + } + if (args.place_after) { + let at = args.place_after; + let hashes = room.digests(plan.document); + if (hashes[at.index] !== at.digest) { + throw new Error(`block ${at.index} has changed; read_plan again.`); + } + let block = parse(room.project(plan.document)).children[at.index]; + if (!block || block.type === "mdxJsxFlowElement") { + throw new Error("place_after must address a prose block"); + } + } + + // Classify every option before the first durable edit, so invalid later input + // cannot leave an earlier title or placement committed by itself. + for (let option of args.add_options) { + if (!option.source) continue; + if (option.source.author.kind !== "member") { + throw new Error("option source must come from a member"); + } + if ( + option.source.role === "option" + && !atomicOptionQuote(option.source.quote, option.label) + ) { + throw new Error("option source does not name one card option"); + } + if (option.source.role === "question") { + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === args.id + ); + if ( + !matchesQuestionSource(option.source, thread) + || !questionMentionsOption(option.source.quote, option.label) + ) { + throw new Error("option question source does not name this card option"); + } + } + let message = plan.chat.entries.find(entry => entry.id === option.source?.messageId); + if (!message) throw new Error("option source message missing from room transcript"); + validateSource(option.source, message); + } + let seen = question.options.map(option => option.label); + let count = question.options.length; + let additions: Option[] = []; + let skipped: string[] = []; + let full: string[] = []; + for (let option of args.add_options) { + if (grouped(option.label) || seen.some(label => sameApproach(label, option.label))) { + skipped.push(option.label); + } else if (count >= 10) full.push(option.label); + else { + seen.push(option.label); + count++; + additions.push(option); + } + } + for (let option of additions) { + if (option.source) continue; + let matches = plan.chat.entries.flatMap(message => { + if (message.author.kind !== "member" || message.streaming) return []; + let handle = message.author.handle; + let candidates; + try { + candidates = extractQuotes(message.text); + } catch { + return []; + } + return candidates.filter(candidate => atomicOptionQuote(candidate.quote, option.label)).map( + candidate => ({ message, candidate, handle }), + ); + }); + if (matches.length !== 1) continue; + let { message, candidate, handle } = matches[0]!; + option.source = { + messageId: message.id, + author: { kind: "member", handle }, + ...candidate, + role: "option", + }; + } + + // Placement runs first, against the exact prose digest supplied by the job. + // A later title or option changes only the card, never this prose address. + let placed = false; + if (args.place_after) { + ensureActive(); + placed = await placeCard(context, args.id, args.place_after, ensureActive); + } + let title = question.question; + if (args.title !== undefined) { + ensureActive(); + let done = await Questions.retitle( + plan, + context.server, + context.room, + args.id, + args.title, + true, + ensureActive, + ); + if (!done.ok) throw new Error(done.message); + title = args.title; + } + let added: string[] = []; + for (let option of additions) { + ensureActive(); + let result = await Questions.addServerOption( + plan, + context.server, + context.room, + args.id, + { + label: option.label, + rationale: option.rationale, + origin: "planner", + ...(option.source ? { source: option.source } : {}), + }, + true, + ensureActive, + ); + if (!result.ok) throw new Error(`option addition was refused: ${result.reason}`); + added.push(result.optionId); + } + return { output: { title, added: added.length }, title, added, skipped, full, placed }; + }); +} diff --git a/apps/server/src/agent/revise-fields.ts b/apps/server/src/agent/revise-fields.ts new file mode 100644 index 00000000..dd927b26 --- /dev/null +++ b/apps/server/src/agent/revise-fields.ts @@ -0,0 +1,57 @@ +export type Option = { label: string; rationale: string }; + +export type Input = { revision: number; id: string; title?: string; add_options: Option[] }; + +export function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("revise_open_decision arguments must be an object"); + } + let value = raw as Record; + if ( + !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "id") + || Object.keys(value).some(key => !["revision", "id", "title", "add_options"].includes(key)) + ) throw new Error("revise_open_decision arguments are invalid"); + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + if (typeof value.id !== "string" || !value.id) throw new Error("decision id is required"); + let title: string | undefined; + if (Object.hasOwn(value, "title")) { + title = typeof value.title === "string" ? value.title.trim() : ""; + if (!title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string)) { + throw new Error("title must be 1–80 characters on one line"); + } + } + let add_options: Option[] = []; + if (Object.hasOwn(value, "add_options")) { + if (!Array.isArray(value.add_options) || value.add_options.length > 10) { + throw new Error("add_options must contain at most ten options"); + } + for (let rawOption of value.add_options) { + if ( + !rawOption || typeof rawOption !== "object" || Array.isArray(rawOption) + || Object.keys(rawOption).sort().join(",") !== "label,rationale" + ) throw new Error("each option needs a label and rationale"); + let option = rawOption as Record; + let label = typeof option.label === "string" ? option.label.trim() : ""; + let rationale = typeof option.rationale === "string" ? option.rationale.trim() : ""; + if (!label || label.length > 200 || /[\r\n\u2028\u2029]/.test(label)) { + throw new Error("option label must be 1–200 characters on one line"); + } + if (!rationale || rationale.length > 300) { + throw new Error("option rationale must be 1–300 characters"); + } + add_options.push({ label, rationale }); + } + } + if (!title && add_options.length === 0) throw new Error("title or add_options is required"); + if (title && Object.hasOwn(value, "add_options")) { + throw new Error("revise_open_decision accepts one action per call; use separate calls"); + } + return { + revision: value.revision as number, + id: value.id, + ...(title ? { title } : {}), + add_options, + }; +} diff --git a/apps/server/src/agent/revise-open-decision-1.test.ts b/apps/server/src/agent/revise-open-decision-1.test.ts new file mode 100644 index 00000000..45f3dd41 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-1.test.ts @@ -0,0 +1,167 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("separate direct calls retitle and add sourced options without deciding", async () => { + let context = await opened(); + let beforeHistory = context.plan.records.get(context.id)!.history; + let beforeRevision = context.plan.revision; + let beforeBroadcasts = context.broadcasts.length; + let retitled = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + }), + ); + expect(retitled).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + added: [], + }); + let answer = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Uses repository-scoped permissions." }], + }), + ); + expect(answer).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + }); + expect(answer.added).toHaveLength(1); + let record = context.plan.records.get(context.id)!; + expect(record.definition.questions[0]?.question).toBe(answer.title); + expect(record.optionOrigins[answer.added[0]]).toEqual({ + origin: "planner", + rationale: "Uses repository-scoped permissions.", + }); + expect(record.status).toBe("open"); + expect(record.resolver).toBeUndefined(); + expect(record.choices).toBeUndefined(); + expect(record.history).toEqual(beforeHistory); + expect(Store.get(context.plan.questions, context.id)?.definition).toEqual(record.definition); + expect(room.project(context.plan.document)).toContain(`prompt="${answer.title}"`); + expect(room.project(context.plan.document)).toContain('label="GitHub Apps"'); + expect(context.plan.pendingCardActions).toMatchObject([{ + kind: "option-added", + origin: "planner", + optionId: answer.added[0], + }]); + let stored = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(stored)).source).toBe(room.project(context.plan.document)); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.plan.revision).toBe(beforeRevision + 2); + expect(context.broadcasts.slice(beforeBroadcasts).filter(frame => frame.kind === "plan:update")) + .toHaveLength(2); + expect( + context.broadcasts.slice(beforeBroadcasts).filter(frame => frame.kind === "question:changed"), + ) + .toHaveLength(2); +}); + +test("a failed combined commit leaves no partial title, option, revision, or broadcast", async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeBroadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + commits++; + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.revisePlannerCard(context.plan, context.server, "test", context.id, { + title: "Which auth system should ship first?", + addOptions: [{ label: "GitHub Apps", rationale: "Repository-scoped permissions." }], + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(commits).toBe(1); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.question).toBe( + "What auth system should we use?", + ); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + expect(context.plan.pendingCardActions).toEqual([]); +}); + +test("two-card requests cannot carry an action across card targets", async () => { + let context = await opened(); + let billing = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-b", + header: "Billing", + question: "Which billing provider should we use?", + options: [], + }); + context.request({ + text: "@chopin revise the question for Billing and add an option to Authentication", + }); + let beforeSource = room.project(context.plan.document); + let beforeRecords = structuredClone([...context.plan.records]); + let beforeDrafts = [context.id, billing].map(id => + Store.get(context.plan.questions, id)!.revision + ); + let beforeRevision = context.plan.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("one action per call"); + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [], + }), + ).toContain("one action per call"); + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + }), + ).toContain("must identify this decision in that action"); + expect( + await context.call({ + revision: beforeRevision, + id: billing, + add_options: [{ label: "Stripe", rationale: "Known billing APIs." }], + }), + ).toContain("must identify this decision in that action"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect([...context.plan.records]).toEqual(beforeRecords); + expect([context.id, billing].map(id => Store.get(context.plan.questions, id)!.revision)) + .toEqual(beforeDrafts); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); diff --git a/apps/server/src/agent/revise-open-decision-2.test.ts b/apps/server/src/agent/revise-open-decision-2.test.ts new file mode 100644 index 00000000..4b845fbc --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-2.test.ts @@ -0,0 +1,129 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("contrast and multiple card names cannot authorize the excluded card", async () => { + let context = await opened(); + let billing = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-b", + header: "Billing", + question: "Which billing provider should we use?", + options: [], + }); + let beforeSource = room.project(context.plan.document); + let beforeRecords = structuredClone([...context.plan.records]); + let beforeDrafts = [context.id, billing].map(id => + Store.get(context.plan.questions, id)!.revision + ); + let beforeRevision = context.plan.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let [text, input] of [ + [ + "@chopin add an option to Authentication instead of Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + [ + "@chopin revise the question for Authentication rather than Billing", + { id: billing, title: "Which billing system should ship first?" }, + ], + [ + "@chopin add an option to Authentication and Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + [ + "@chopin add an option to Authentication except Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + ] as const + ) { + context.request({ text }); + expect(await context.call({ revision: beforeRevision, ...input })) + .toContain("must identify this decision in that action"); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect([...context.plan.records]).toEqual(beforeRecords); + expect([context.id, billing].map(id => Store.get(context.plan.questions, id)!.revision)) + .toEqual(beforeDrafts); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); + +test("reference or backscroll instructions cannot authorize an unrequested card edit", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + context.request({ text: "@chopin summarize the Authentication decision" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("did not request a question edit"); + context.request({ text: "@chopin revise the Authentication decision question" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions" }], + }), + ).toContain("did not request new options"); + context.request({ text: "@chopin revise the question for Search" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("must identify this decision"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("negated or withdrawn edit language never authorizes an option", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let text of [ + "@chopin Don't add an option to the Authentication decision.", + "@chopin Please cancel my request to add an option to the Authentication decision.", + ] + ) { + context.request({ text }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); diff --git a/apps/server/src/agent/revise-open-decision-3.test.ts b/apps/server/src/agent/revise-open-decision-3.test.ts new file mode 100644 index 00000000..ee0299db --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-3.test.ts @@ -0,0 +1,87 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("editing an option rationale does not authorize a question retitle", async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ + text: "@chopin, please add an option to the Authentication question and edit the rationale.", + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + }), + ).toContain("did not request a question edit"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); + +test("generic question words and ambiguous references do not identify a card", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let [text, refusal] of [ + ["@chopin What options should we add?", "did not request new options"], + ["@chopin Add an option to this decision.", "must identify this decision"], + ] as const + ) { + context.request({ text }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain(refusal); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); + +test("a named card in a deliberative question still does not authorize an edit", async () => { + let context = await opened(); + context.request({ text: "@chopin What options should we add to the Authentication decision?" }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); diff --git a/apps/server/src/agent/revise-open-decision-4.test.ts b/apps/server/src/agent/revise-open-decision-4.test.ts new file mode 100644 index 00000000..3f6dcae9 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-4.test.ts @@ -0,0 +1,86 @@ +import { expect, spyOn, test } from "bun:test"; +import { plannerConfiguration } from "./scoped-tools.test-configuration"; +import * as room from "../plan/room"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +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)).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))); + expect(snapshot.questions.find((item: { id: string }) => item.id === context.id).options) + .toEqual([["Anchors"]]); +}); + +test("direct card edit requires a current member request and is fenced from jobs", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + context.request(); + expect(await context.call({ revision: context.plan.revision, id: context.id, title: "Why?" })) + .toContain("current member request"); + context.request({ text: "@chopin revise the Authentication decision question" }); + context.plan.chat.job = { + id: `refine:${context.id}:m1`, + kind: "refine", + target: context.id, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + expect(await context.call({ revision: context.plan.revision, id: context.id, title: "Why?" })) + .toContain("current member request"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("stale revisions, closed cards, duplicate labels and option limits refuse before mutation", async () => { + let context = await opened([{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Anchors" }]); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let base = { revision: context.plan.revision, id: context.id }; + expect(await context.call({ ...base, revision: base.revision + 1, title: "Why now?" })) + .toContain("read_plan again"); + expect( + await context.call({ + ...base, + add_options: [{ label: " anchors ", rationale: "Duplicate" }], + }), + ) + .toContain("duplicate option"); + expect( + await context.call({ + ...base, + add_options: Array.from({ length: 10 }, (_, i) => ({ + label: `Option ${i}`, + rationale: "Evidence", + })), + }), + ).toContain("too many options"); + context.plan.records.delete(context.id); + expect(await context.call({ ...base, title: "Why now?" })).toContain("no longer open"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); +}); diff --git a/apps/server/src/agent/revise-open-decision-5.test.ts b/apps/server/src/agent/revise-open-decision-5.test.ts new file mode 100644 index 00000000..ab3cd18d --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-5.test.ts @@ -0,0 +1,113 @@ +import { expect, spyOn, test } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("an active implementation refuses direct card edits", async () => { + let context = await opened(); + let active = spyOn(Service, "implementationActive").mockReturnValue(true); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("implementation is active"); + } finally { + active.mockRestore(); + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("a queued direct tool cannot write after the member turn changes", async () => { + let context = await opened(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => { + entered.resolve(); + return release.promise.then(() => Service.exclusive(context.plan, action)); + }, + async publish() {}, + anchors() {}, + changes() {}, + currentMemberRequest: () => context.plan.chat.activeRequest, + }).find(item => item.name === "revise_open_decision")!; + context.plan.chat.activeRequest = { + entryId: "m1", + userId: "u1", + handle: "ana", + text: "Revise the Authentication decision question", + claimantSessionId: "s1", + turnId: "turn-1", + lifecycle: 1, + }; + let pending = Promise.resolve(found.handler!({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }, {} as never)).then(String); + await entered.promise; + context.plan.chat.turn = { id: "turn-2", handle: "ana", started: 2, responded: false }; + release.resolve(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await pending).toContain("Planner turn changed"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("a replaced member request cannot publish a staged card edit", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let original = room.restore; + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let pending = context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }); + await entered.promise; + context.request({ text: "@chopin revise another decision question" }); + release.resolve(); + expect(await pending).toContain("Planner turn changed"); + } finally { + held.mockRestore(); + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); diff --git a/apps/server/src/agent/revise-open-decision-requests.test.ts b/apps/server/src/agent/revise-open-decision-requests.test.ts new file mode 100644 index 00000000..4c83729d --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-requests.test.ts @@ -0,0 +1,124 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +for ( + let [name, text] of [ + ["curly-apostrophe negation", "@chopin Don’t add an option to the Authentication decision."], + [ + "deliberative question", + "@chopin Would it be useful to add an option to the Authentication decision?", + ], + [ + "later withdrawal", + "@chopin, please add an option to the Authentication decision — actually, don't.", + ], + ] as const +) { + test(`${name} cannot change any card state`, async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ text }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + }); +} + +for ( + let [name, text, title, refusal] of [ + [ + "a note about options", + "@chopin please add a note to the Authentication decision about options", + undefined, + "did not request new options", + ], + [ + "a rationale edit with a distant question noun", + "@chopin please edit the rationale for the Authentication decision question", + "Which auth system should ship first?", + "did not request a question edit", + ], + ] as const +) { + test(`${name} cannot change any card state`, async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ text }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + ...(title ? { title } : { + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + }), + ).toContain(refusal); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + }); +} + +for ( + let text of [ + "@chopin, please add an option called GitHub Apps to the Authentication decision.", + "@chopin Can you add an option called GitHub Apps to the Authentication decision?", + ] +) { + test(`affirmative direct request authorizes an option: ${text}`, async () => { + let context = await opened(); + context.request({ text }); + let answer = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ); + expect(answer.ok).toBe(true); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options) + .toHaveLength(1); + }); +} diff --git a/apps/server/src/agent/revise-open-decision.test-fixtures.ts b/apps/server/src/agent/revise-open-decision.test-fixtures.ts new file mode 100644 index 00000000..3e078f28 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision.test-fixtures.ts @@ -0,0 +1,58 @@ +import { afterEach } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; + +export function createReviseOpenDecisionFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + let contexts: Awaited>[] = []; + + async function opened(options: Array<{ id: string; label: string }> = []) { + let context = await openPlan("Opening prose.\n"); + contexts.push(context); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }); + context.plan.chat.busy = true; + context.plan.chat.turn = { id: "turn-1", handle: "ana", started: 1, responded: false }; + let request: { text: string } | undefined = { + text: + "@chopin revise the question for the Authentication decision and add an option to the Authentication decision", + }; + let tools = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => Service.exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + currentMemberRequest: () => request, + }); + let found = tools.find(item => item.name === "revise_open_decision"); + if (!found?.handler) throw new Error("revise_open_decision is missing"); + let call = (input: unknown) => Promise.resolve(found.handler!(input, {} as never)).then(String); + return { ...context, id, tools, call, request: (value?: { text: string }) => request = value }; + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { contexts, opened }; +} diff --git a/apps/server/src/agent/revise-open-decision.ts b/apps/server/src/agent/revise-open-decision.ts new file mode 100644 index 00000000..0e2d1fa9 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision.ts @@ -0,0 +1,69 @@ +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { input } from "./revise-fields"; +import { explicitRequest } from "./revise-requests"; +import type { Context } from "./card-tool-context"; + +export async function reviseOpenDecision( + context: Context, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ ok: true; title: string; added: string[] }> { + let request = context.currentMemberRequest?.(); + if (!request || !turn || context.plan.chat.job) { + throw new Error("a direct current member request is required"); + } + return context.exclusive(async () => { + let { plan } = context; + let ensureActive = () => { + if ( + plan.chat.turn !== turn || plan.chat.job + || context.currentMemberRequest?.() !== request + ) throw new Error("the member's Planner turn changed before its tool completed"); + }; + ensureActive(); + let args = input(raw); + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(args.id); + let live = Store.get(plan.questions, args.id); + if ( + !record || !Questions.isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, args.id, record.definition.questions[0]!.id) + ) throw new Error("that decision is no longer open in this document"); + let question = record.definition.questions[0]!; + explicitRequest( + request.text, + { id: args.id, header: question.header, question: question.question }, + [...plan.records.values()].map(item => ({ + id: item.id, + header: item.definition.questions[0]?.header ?? "", + question: item.definition.questions[0]?.question ?? "", + })), + args, + ); + let seen = new Set(question.options.map(option => option.label.trim().toLocaleLowerCase())); + if (question.options.length + args.add_options.length > 10) { + throw new Error("decision has too many options"); + } + for (let option of args.add_options) { + let folded = option.label.toLocaleLowerCase(); + if (seen.has(folded)) throw new Error(`duplicate option: ${option.label}`); + seen.add(folded); + } + let result = await Questions.revisePlannerCard( + plan, + context.server, + context.room, + args.id, + { title: args.title, addOptions: args.add_options }, + true, + ensureActive, + ); + return { ok: true, ...result }; + }); +} diff --git a/apps/server/src/agent/revise-requests.ts b/apps/server/src/agent/revise-requests.ts new file mode 100644 index 00000000..a350d27c --- /dev/null +++ b/apps/server/src/agent/revise-requests.ts @@ -0,0 +1,90 @@ +import type { Input } from "./revise-fields"; + +export type Target = { id: string; header: string; question: string }; + +export type RequestedAction = { kind: "title" | "option"; clause: string }; + +export function words(value: string): string[] { + return value.toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +} + +export function names(target: Target): string[] { + return [target.id, target.header, target.question] + .map(value => words(value).join(" ")) + .filter(Boolean); +} + +export function requestedActions(text: string): RequestedAction[] { + let message = text.toLocaleLowerCase().replace(/[\u2018\u2019]/g, "'") + .replace(/^\s*@chopin\b[\s,:-]*/, "").trim(); + let verbs = "change|edit|rename|retitle|reword|revise|update|add|include|propose|suggest"; + let start = new RegExp( + `^(?:(?:please|kindly)\\s+)?(?:(?:(?:can|could|would) you\\s+(?:please\\s+)?` + + `|i (?:want|would like) you to\\s+|i'd like you to\\s+))?(${verbs})\\b`, + ); + let first = start.exec(message); + let actions: RequestedAction[] = []; + if (!first) return actions; + // A withdrawal anywhere in the same message cancels the whole proposed batch. + if (/\b(?:don't|do not|not|never|no|cancel|withdraw|retract|stop)\b/.test(message)) { + return actions; + } + let commands: Array<{ verb: string; objectStart: number; boundary: number }> = [{ + verb: first[1]!, + objectStart: first[0].length, + boundary: 0, + }]; + let continuation = new RegExp(`\\b(?:and|then)\\s+(?:please\\s+)?(${verbs})\\b`, "g"); + for (let match of message.slice(first[0].length).matchAll(continuation)) { + let boundary = first[0].length + match.index!; + commands.push({ verb: match[1]!, objectStart: boundary + match[0].length, boundary }); + } + for (let [index, command] of commands.entries()) { + let clause = message.slice(command.objectStart, commands[index + 1]?.boundary); + if (/^(?:add|include|propose|suggest)$/.test(command.verb)) { + if ( + /^\s*(?:(?:an?|some|new|the)\s+)?(?:option|options|choice|choices|alternative|alternatives)\b/ + .test(clause) + ) { + actions.push({ kind: "option", clause }); + } + } else if (/^\s*(?:the\s+)?(?:question|title|wording)\b/.test(clause)) { + actions.push({ kind: "title", clause }); + } + } + return actions; +} + +export function explicitRequest( + text: string, + target: Target, + allTargets: Target[], + args: Input, +): void { + let actions = requestedActions(text); + let supports = (kind: RequestedAction["kind"], refusal: string) => { + let matches = actions.filter(action => action.kind === kind); + if (matches.length === 0) throw new Error(refusal); + let named = matches.some(action => { + let clause = ` ${words(action.clause).join(" ")} `; + if (/\b(?:instead of|rather than|except)\b/.test(clause)) return false; + let mentioned = allTargets.filter(candidate => + names(candidate).some(name => clause.includes(` ${name} `)) + ); + if (mentioned.length !== 1 || mentioned[0]?.id !== target.id) return false; + return [...clause.matchAll(/\b(?:to|for)\s+(?:the\s+)?/g)].some(match => { + let after = clause.slice(match.index! + match[0].length); + return names(target).some(name => after.startsWith(`${name} `)); + }); + }); + if (!named) { + throw new Error("the current member request must identify this decision in that action"); + } + }; + if (args.title !== undefined) { + supports("title", "the current member did not request a question edit"); + } + if (args.add_options.length > 0) { + supports("option", "the current member did not request new options"); + } +} diff --git a/apps/server/src/agent/scoped-prose-tool.ts b/apps/server/src/agent/scoped-prose-tool.ts new file mode 100644 index 00000000..001d8d1a --- /dev/null +++ b/apps/server/src/agent/scoped-prose-tool.ts @@ -0,0 +1,37 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { write } from "./decision-prose"; +import { runJobTool } from "./job-scope"; + +export function createProseTools() { + return { + write_decision_prose: tool({ + description: "Background job only. Write one grounded paragraph for a saved conversation " + + "decision. At most 600 characters; inline Markdown is allowed. Re-decisions replace the " + + "existing paragraph.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + text: { type: "string", minLength: 1, maxLength: 600 }, + }, + required: ["revision", "id", "text"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + return answer("write_decision_prose", () => { + let turn = context.plan.chat.turn; + return runJobTool( + context.plan.chat, + "write_decision_prose", + job => write(context, job, turn, raw), + ); + }); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-refine-tool.ts b/apps/server/src/agent/scoped-refine-tool.ts new file mode 100644 index 00000000..496dd7fc --- /dev/null +++ b/apps/server/src/agent/scoped-refine-tool.ts @@ -0,0 +1,80 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { refineDecision } from "./refine-tool"; +import { runJobTool } from "./job-scope"; + +export function createRefineTools() { + return { + refine_decision: tool({ + description: "Background job only. Improve one open decision: a refine job may reword its " + + "question and place it after related prose; a suggest job may only add reasoned " + + "options. Adding no option is valid when the evidence supports none.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + title: { type: "string", minLength: 1, maxLength: 80 }, + add_options: { + type: "array", + maxItems: 10, + items: { + type: "object", + properties: { + label: { type: "string", minLength: 1, maxLength: 200 }, + rationale: { type: "string", minLength: 1, maxLength: 300 }, + source: { + type: "object", + properties: { + messageId: { type: "string" }, + author: { + type: "object", + properties: { + kind: { type: "string", enum: ["member", "agent"] }, + handle: { type: "string" }, + }, + required: ["kind"], + additionalProperties: false, + }, + quote: { type: "string" }, + start: { type: "integer", minimum: 0 }, + end: { type: "integer", minimum: 1 }, + role: { type: "string", enum: ["option", "question"] }, + }, + required: ["messageId", "author", "quote", "start", "end", "role"], + additionalProperties: false, + }, + }, + required: ["label", "rationale"], + additionalProperties: false, + }, + }, + place_after: { + type: "object", + properties: { + index: { type: "integer", minimum: 0 }, + digest: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" }, + }, + required: ["index", "digest"], + additionalProperties: false, + }, + }, + required: ["revision", "id"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + return answer("refine_decision", () => { + let turn = context.plan.chat.turn; + return runJobTool( + context.plan.chat, + "refine_decision", + job => refineDecision(context, job, turn, raw), + ); + }); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-request-boundary.test.ts b/apps/server/src/agent/scoped-request-boundary.test.ts new file mode 100644 index 00000000..46928013 --- /dev/null +++ b/apps/server/src/agent/scoped-request-boundary.test.ts @@ -0,0 +1,47 @@ +import { expect, spyOn, test } from "bun:test"; +import { createScopedTools } from "./scoped-tools"; +import { documentRoom } from "./heading-harness.test-fixtures"; +import { openPlan } from "../testing/plan"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Room from "../plan/room"; + +test("raw transcript request state cannot authorize a direct tool without its verified provider", async () => { + let context = await openPlan("Opening prose.\n"); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "Which authentication system?", + options: [], + }); + context.plan.chat.turn = { id: "current-turn", handle: "ana", started: 1, responded: false }; + context.plan.chat.activeRequest = { + entryId: "old-entry", + userId: "U_test", + handle: "ana", + claimantSessionId: "old-session", + turnId: "old-turn", + lifecycle: context.plan.chat.lifecycle - 1, + text: "@chopin revise the question for the Authentication decision", + }; + let room = documentRoom(context); + expect(room.currentMemberRequest).toBeUndefined(); + let before = Room.project(context.plan.document); + let record = structuredClone(context.plan.records.get(id)); + let revision = context.plan.revision; + let result = await createScopedTools().revise_open_decision.execute!({ + revision, + id, + title: "Authentication choice", + }, { context: { room }, toolCallId: "raw-request", messages: [] }); + expect(result).toBe("Error: a direct current member request is required"); + expect(Room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(id)).toEqual(record); + expect(context.plan.revision).toBe(revision); + } finally { + errors.mockRestore(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/agent/scoped-revise-tool.ts b/apps/server/src/agent/scoped-revise-tool.ts new file mode 100644 index 00000000..be0cc76f --- /dev/null +++ b/apps/server/src/agent/scoped-revise-tool.ts @@ -0,0 +1,53 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { reviseOpenDecision } from "./revise-open-decision"; + +export function createDirectTools() { + return { + revise_open_decision: tool({ + description: "In a direct member-requested Chat turn, perform exactly one action " + + "on one existing open decision explicitly named in that action's clause: " + + "either reword its question or add evidence-backed options with rationales. " + + "Use separate calls for separate actions, reading the revision again between calls. " + + "Never create a card, rename an existing option, or choose an answer.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + title: { + type: "string", + minLength: 1, + maxLength: 80, + description: "Only for a question retitle call; omit add_options.", + }, + add_options: { + type: "array", + description: "Only for an option addition call; omit title.", + minItems: 1, + maxItems: 10, + items: { + type: "object", + properties: { + label: { type: "string", minLength: 1, maxLength: 200 }, + rationale: { type: "string", minLength: 1, maxLength: 300 }, + }, + required: ["label", "rationale"], + additionalProperties: false, + }, + }, + }, + required: ["revision", "id"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + + let turn = context.plan.chat.turn; + return answer("revise_open_decision", () => reviseOpenDecision(context, turn, raw)); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-tool-result.ts b/apps/server/src/agent/scoped-tool-result.ts new file mode 100644 index 00000000..c07dca36 --- /dev/null +++ b/apps/server/src/agent/scoped-tool-result.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import type { CardToolRoom } from "./card-tool-context"; + +export let cardToolSchema = z.object({ + room: z.custom(value => + !!value && typeof value === "object" + && typeof (value as CardToolRoom).id === "string" && !!(value as CardToolRoom).plan + ), +}); + +export async function answer(name: string, produce: () => unknown): Promise { + try { + return JSON.stringify(await produce(), null, 2) ?? "null"; + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + console.error(`[agent/${name}]`, err); + return `Error: ${message}`; + } +} diff --git a/apps/server/src/agent/scoped-tools.test-bridge.ts b/apps/server/src/agent/scoped-tools.test-bridge.ts new file mode 100644 index 00000000..902d7f8e --- /dev/null +++ b/apps/server/src/agent/scoped-tools.test-bridge.ts @@ -0,0 +1,25 @@ +import { createScopedTools } from "./scoped-tools"; +import { documentTools } from "./tools"; +import type { CardToolRoom, Context } from "./card-tool-context"; +import type { ToolSet } from "ai"; + +type ArchivedContext = Omit & { + room: string; + currentMemberRequest?: Context["currentMemberRequest"]; +}; + +/** Executes current AI SDK tools for the unchanged archived test callbacks. */ +export function toolbox(context: ArchivedContext) { + let room = { ...context, id: context.room }; + let tools: ToolSet = { ...documentTools, ...createScopedTools() }; + return Object.entries(tools).map(([name, value]) => ({ + name, + skipPermission: value.metadata?.skipPermission === true, + handler: (raw: unknown, _options: never) => + value.execute!(raw as never, { + context: { room: room as CardToolRoom }, + toolCallId: "scoped-test", + messages: [], + }), + })); +} diff --git a/apps/server/src/agent/scoped-tools.test-configuration.ts b/apps/server/src/agent/scoped-tools.test-configuration.ts new file mode 100644 index 00000000..b5f4e994 --- /dev/null +++ b/apps/server/src/agent/scoped-tools.test-configuration.ts @@ -0,0 +1,86 @@ +import { createPlannerAgent } from "../harness/agents"; +import type { HarnessV1 } from "@ai-sdk/harness"; +import type { toolbox } from "./scoped-tools.test-bridge"; + +let offered: string[] = []; +let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "scoped-metadata", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(turn) { + offered = turn.tools.map(spec => spec.name); + queueMicrotask(() => + turn.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }) + ); + return { done: Promise.resolve(), async submitToolResult() {} }; + }, + async doDestroy() {}, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + throw new Error("unused"); + }, + }; + }, +}; +let agent = createPlannerAgent(fake); +let session = await agent.createSession({ + sessionId: "scoped-metadata", + sandboxSession: { + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, + } as never, +}); +try { + let result = await agent.stream({ + session, + prompt: "Observe tool registration", + options: { + room: { id: "metadata", plan: { chat: {} } } as never, + repository: {} as never, + owner: {} as never, + githubTools: {}, + instructions: "Observe only", + }, + }); + for await (let _part of result.fullStream) {} +} finally { + await session.destroy(); +} + +/** Legacy assertion shape, derived only from a real current Harness turn's offered tools. */ +export function plannerConfiguration( + _config: { model: string }, + options: { tools: ReturnType }, + _owner: unknown, +) { + return { + availableTools: offered.length ? ["custom:*"] : [], + tools: options.tools.filter(value => offered.includes(value.name)).map(value => ({ + ...value, + skipPermission: agent.tools[value.name]?.metadata?.skipPermission === true, + })), + }; +} diff --git a/apps/server/src/agent/scoped-tools.ts b/apps/server/src/agent/scoped-tools.ts new file mode 100644 index 00000000..634bbf35 --- /dev/null +++ b/apps/server/src/agent/scoped-tools.ts @@ -0,0 +1,7 @@ +import { createRefineTools } from "./scoped-refine-tool"; +import { createDirectTools } from "./scoped-revise-tool"; +import { createProseTools } from "./scoped-prose-tool"; + +export function createScopedTools() { + return { ...createRefineTools(), ...createDirectTools(), ...createProseTools() }; +} diff --git a/apps/server/src/agent/tools.ts b/apps/server/src/agent/tools.ts index d933222a..a3609451 100644 --- a/apps/server/src/agent/tools.ts +++ b/apps/server/src/agent/tools.ts @@ -1,3 +1,4 @@ +import type { ActiveMemberRequest } from "../chat/service"; /** * What the planner can do. * @@ -83,6 +84,8 @@ export type DocumentRoom = { /** Tells the room where this batch wrote, moved and removed. */ changes: (found: edit.Change[]) => void; jobs?: JobService; + /** The same verified member request that owns the current live turn. */ + currentMemberRequest?: () => ActiveMemberRequest | undefined; /** Starts the exact research request represented by the current member turn. */ createResearch?: (question: string) => Promise; /** Reads one reference retained by this room's active Planner session. */ @@ -135,6 +138,9 @@ export const documentTools = { id: record.id, status: record.status, questions: record.definition.questions.map(question => question.question), + options: record.definition.questions.map(question => + question.options.map(option => option.label) + ), ...(record.answers ? { answers: record.answers } : {}), ...(record.resolver ? { answered_by: record.resolver } : {}), })), diff --git a/apps/server/src/chat/job-authenticated-memory.test-fixtures.ts b/apps/server/src/chat/job-authenticated-memory.test-fixtures.ts new file mode 100644 index 00000000..815ad11c --- /dev/null +++ b/apps/server/src/chat/job-authenticated-memory.test-fixtures.ts @@ -0,0 +1,97 @@ +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { Sessions } from "../auth/session"; +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { headingHarness } from "./job-heading-harness.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { HostedAuth } from "../auth/routes"; +import type { openPlan } from "../testing/plan"; + +function clock(): Date { + return new Date(); +} + +async function noop(_output?: unknown): Promise {} + +export async function authenticatedMemory( + opened: Awaited>, + kind: ConversationPlan.JobKind = "heading", + call?: { name: string; input: Record }, +) { + let plan = opened.plan; + let sessions = new Sessions(opened.storage, true, clock); + let session = await sessions.issue("U_test", { + accessToken: "test-token", + accessExpiresIn: 28_800, + refreshToken: "test-refresh", + refreshExpiresIn: 15_897_600, + }); + let repository = { + id: opened.channel.repositoryId, + owner: "owner", + name: "repository", + defaultBranch: "main", + }; + let auth = { + storage: opened.storage, + sessions, + clock, + github: { + repositoryAccess: async () => ({ ...repository, permissions: { push: true, admin: false } }), + }, + admission: { allowed: async () => true }, + } as unknown as HostedAuth; + let owners = new ActiveOwnerBindings(auth); + let startCheck: () => Promise = noop; + let resultCheck: (output: unknown) => Promise = noop; + let driver = headingHarness(() => startCheck(), output => resultCheck(output), call); + let headingAgent = createPlannerAgent(driver.fake, kind); + let sandboxDestroys = 0; + let context: Chat.Room = { + chat: plan.chat, + plan, + server: opened.server, + room: opened.channel.id, + config: { agent: true } as Chat.Room["config"], + auth, + repository, + claimantSessionId: session.id, + activeOwner: () => owners.resolve(opened.channel.id), + persist: () => Plan.persist(plan), + openPlannerSession: (owner, channel) => + openPlannerSession(owner, channel, { + headingAgent, + refineAgent: headingAgent, + proseAgent: headingAgent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: () => () => {}, + }), + }; + + return { + context, + driver, + sessionId: session.id, + revokeAll: () => owners.revokeAll(), + invalidateOwner: () => owners.revokeCredential(session.id, 1), + sandboxDestroys: () => sandboxDestroys, + onStart(check: typeof startCheck) { + startCheck = check; + }, + onResult(check: typeof resultCheck) { + resultCheck = check; + }, + }; +} diff --git a/apps/server/src/chat/job-boundary.test.ts b/apps/server/src/chat/job-boundary.test.ts new file mode 100644 index 00000000..43602041 --- /dev/null +++ b/apps/server/src/chat/job-boundary.test.ts @@ -0,0 +1,71 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import { runJobTool } from "../agent/job-scope"; +import * as Chat from "./service"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("a job refuses an inactive write even after its own tool recorded output", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + await runJobTool(h.chat, "refine_decision", async () => ({ output: {} })); + h.emitPart({ type: "tool-call", toolCallId: "bad", toolName: "edit_plan", input: {} }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: edit_plan", + }); + expect(h.chat.entries).toEqual([]); +}); + +test("a job does not trust a result without its live call identity", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ + type: "tool-result", + toolCallId: "unknown", + toolName: "refine_decision", + input: {}, + output: '{"added":1}', + }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: unrecognized job tool result", + }); + expect(h.chat.jobOutput).toBeUndefined(); +}); + +test("a tool result cannot replace output produced by the captured own-tool wrapper", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ type: "tool-call", toolCallId: "own", toolName: "refine_decision", input: {} }); + h.emitPart({ + type: "tool-result", + toolCallId: "own", + toolName: "refine_decision", + input: {}, + output: '{"added":1}', + }); + h.emit("session.idle"); + expect(await running).toEqual({ + status: "failed", + reason: "The Planner ended without calling refine_decision.", + }); +}); + +test("a job aborts a permission refusal without publishing tool details", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ type: "tool-call", toolCallId: "own", toolName: "refine_decision", input: {} }); + h.emitPart({ type: "tool-output-denied", toolCallId: "own", toolName: "refine_decision" }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool permission was denied.", + }); + expect(h.chat.entries).toEqual([]); + expect(h.frames.some(frame => frame.kind === "chat:tool")).toBe(false); +}); diff --git a/apps/server/src/chat/job-card-memory.test-fixtures.ts b/apps/server/src/chat/job-card-memory.test-fixtures.ts new file mode 100644 index 00000000..6be1ea33 --- /dev/null +++ b/apps/server/src/chat/job-card-memory.test-fixtures.ts @@ -0,0 +1,131 @@ +import { expect } from "bun:test"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerJobs } from "../conversation-plan/planner-jobs"; +import { PROMPT_FOR } from "../conversation-plan/job-prompts"; +import { JOB_TOOLS } from "../agent/job-scope"; +import { documentTools } from "../agent/tools"; +import { scopedJobTools } from "../agent/job-tools"; + +export const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +export const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +export const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; +const WIDGET = ` + + + +`; + +export async function cardMemory(kind: "refine" | "suggest" | "prose") { + let answered = Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + }); + let opened = kind === "prose" + ? await openPlan(`Context.\n\n${WIDGET}`, { questions: [answered] }) + : await openPlan("Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let id = kind === "prose" + ? CARD + : await Questions.insertConversationCard(plan, opened.server, opened.channel.id, { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options: [], + }); + plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "We need to choose authentication.", + ts: 1, + }); + await Plan.persist(plan); + let raw = kind === "prose" + ? { revision: plan.revision, id, text: "We chose GitHub Apps." } + : kind === "refine" + ? { revision: plan.revision, id, title: "How should people sign in?", add_options: [] } + : { + revision: plan.revision, + id, + add_options: [{ label: "Passkeys", rationale: "Avoids passwords for account access." }], + }; + let identity = await authenticatedMemory(opened, kind, { name: JOB_TOOLS[kind], input: raw }); + let errors: unknown[] = []; + identity.onStart(async () => { + let before = Plan.source(plan); + let guarded = scopedJobTools(documentTools, Chat.documentRoom(identity.context)); + let result = await guarded.edit_plan!.execute!( + { + revision: plan.revision, + operations: [{ op: "insert_root", source: "Unauthorized paragraph." }], + }, + { + toolCallId: "foreign", + messages: [], + context: { room: Chat.documentRoom(identity.context) }, + } as never, + ); + expect(String(result)).toContain(`use only ${JOB_TOOLS[kind]}`); + expect(Plan.source(plan)).toBe(before); + }); + identity.onResult(async output => { + expect(String(output)).toContain('"ok": true'); + identity.driver.finish(); + }); + let jobs = createPlannerJobs({ + plan, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + runner: (job, prompt, signal) => + Chat.job(identity.context, job, prompt, identity.sessionId, signal), + prompt: job => PROMPT_FOR[job.kind]?.(plan, job), + publishJobs: current => + opened.server.publish( + opened.channel.id, + JSON.stringify({ kind: "conversation-plan:jobs", jobs: current }), + ), + publishMeta: target => Questions.announce(plan, opened.server, opened.channel.id, target), + activity: async text => { + await Chat.noticeExclusive(identity.context, text); + }, + onError: error => errors.push(error), + }); + let trigger = kind === "prose" ? `decided:${CARD}:1` : "m1"; + return { + ...identity, + opened, + plan, + jobs, + id, + errors, + async run() { + await jobs.enqueue({ kind, target: id, trigger }); + await jobs.idle(); + }, + async close() { + jobs.stop(); + identity.revokeAll(); + await jobs.idle(); + await Plan.close(plan); + }, + }; +} diff --git a/apps/server/src/chat/job-card-memory.test.ts b/apps/server/src/chat/job-card-memory.test.ts new file mode 100644 index 00000000..a31fa63e --- /dev/null +++ b/apps/server/src/chat/job-card-memory.test.ts @@ -0,0 +1,54 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { cardMemory } from "./job-card-memory.test-fixtures"; + +installJobCleanup(); + +for (let kind of ["refine", "suggest", "prose"] as const) { + test(`current ${kind} Harness profile executes its own durable card tool and reopens without replay`, async () => { + let h = await cardMemory(kind); + try { + await h.run(); + expect(h.plan.conversationPlanJobs).toMatchObject([{ status: "done", attempts: 1 }]); + let record = h.plan.records.get(h.id)!; + if (kind === "refine") { + expect(record.definition.questions[0]?.question).toBe("How should people sign in?"); + } + if (kind === "suggest") { + expect(record.definition.questions[0]?.options.map(option => option.label)).toEqual([ + "Passkeys", + ]); + } + if (kind === "prose") { + expect(Plan.source(h.plan)).toContain("We chose GitHub Apps."); + expect(record.prose).toHaveLength(1); + expect(record.prose?.[0]?.orphaned).not.toBe(true); + } + expect(h.plan.chat.entries).toHaveLength(2); + expect(h.plan.chat.entries[0]?.text).toBe("We need to choose authentication."); + expect(h.plan.chat.entries[1]?.author.kind).toBe("system"); + expect(h.plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + h.opened.broadcasts.some(frame => + frame.kind === "chat:tool" || frame.kind === "chat:delta" + ), + ).toBe(false); + expect(h.plan.chat.job).toBeUndefined(); + expect(h.errors).toEqual([]); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(reopened.records.get(h.id)?.definition).toEqual(record.definition); + expect(reopened.conversationPlanJobs).toMatchObject([{ status: "done", attempts: 1 }]); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + if (kind === "prose") expect(Plan.source(reopened)).toContain("We chose GitHub Apps."); + } finally { + await Plan.close(reopened); + } + } finally { + if (!h.plan.chat.closed) await h.close(); + } + }); +} diff --git a/apps/server/src/chat/job-fence.ts b/apps/server/src/chat/job-fence.ts new file mode 100644 index 00000000..67600f34 --- /dev/null +++ b/apps/server/src/chat/job-fence.ts @@ -0,0 +1,20 @@ +import { jobReason, type JobTurn } from "./job-queue"; +import type { Chat } from "./service"; + +/** A cancelled current turn must lose its tool's live job identity before any late write. */ +export function watchJobAbort(chat: Chat, turn: JobTurn, signal: AbortSignal): () => void { + let currentTurn = chat.turn; + function abort() { + if (chat.job !== turn.job || chat.turn !== currentTurn) return; + chat.job = undefined; + chat.jobOutput = undefined; + chat.interruption ??= chat.closed + ? "The document closed." + : signal.reason instanceof Error && signal.reason.name !== "AbortError" + ? jobReason(signal.reason) + : "The Planner turn was stopped."; + } + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + return () => signal.removeEventListener("abort", abort); +} diff --git a/apps/server/src/chat/job-handoff.test.ts b/apps/server/src/chat/job-handoff.test.ts new file mode 100644 index 00000000..8a192664 --- /dev/null +++ b/apps/server/src/chat/job-handoff.test.ts @@ -0,0 +1,47 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("a queue handoff persist failure settles the shifted job", async () => { + let state = harness(); + let commits = 0; + state.context.persist = async () => { + commits++; + if (commits === 3) throw new Error("handoff unavailable"); + }; + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "later", "session"); + state.emit("session.idle"); + expect(await queued).toEqual({ status: "failed", reason: "handoff unavailable" }); + expect(state.prompts).toHaveLength(1); + expect(state.chat.busy).toBe(false); + expect(state.chat.waiting).toEqual([]); +}); + +test("closing during queue handoff settles a job already removed from waiting", async () => { + let state = harness(); + let commits = 0; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + state.context.persist = async () => { + commits++; + if (commits === 3) { + entered.resolve(); + await release.promise; + } + }; + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "later", "session"); + state.emit("session.idle"); + await entered.promise; + expect(state.chat.waiting).toEqual([]); + let closing = Chat.close(state.chat); + release.resolve(); + await closing; + expect(await queued).toEqual({ status: "failed", reason: "The document closed." }); +}); diff --git a/apps/server/src/chat/job-harness.test-fixtures.ts b/apps/server/src/chat/job-harness.test-fixtures.ts new file mode 100644 index 00000000..28f43c84 --- /dev/null +++ b/apps/server/src/chat/job-harness.test-fixtures.ts @@ -0,0 +1,68 @@ +import * as Chat from "./service"; +import { jobStream } from "./job-stream.test-fixtures"; + +export function harness(agent = true, chats: Chat.Chat[]) { + let chat = Chat.create(); + chats.push(chat); + let stream = jobStream(); + let frames: Array> = []; + let brokenFrame: string | undefined; + let ownerAvailable = true; + let expiresAt = new Date(Date.now() + 60 * 60 * 1000); + let owner = { access: { token: "gho_test", revision: 1, expiresAt }, session: { expiresAt } }; + let repository = { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }; + let context = { + chat, + plan: { chat }, + server: { + publish(_topic: string, body: string) { + let frame = JSON.parse(body) as Record; + if (frame.kind === brokenFrame) throw new Error("socket broadcast failed"); + frames.push(frame); + }, + }, + room: "room", + config: { agent }, + claimantSessionId: "session", + repository, + persist: async () => {}, + activeOwner: async () => ({ + ownerSessionId: "session", + ownerGeneration: 1, + credentialRevision: 1, + repository, + signal: new AbortController().signal, + revalidate: async () => true, + release() {}, + }), + openPlannerSession: async () => ({ ok: true, value: stream.session() }), + auth: { + storage: { + channels: { + claimAgentOwner: async () => ({ + ownerSessionId: ownerAvailable ? "session" : undefined, + generation: 1, + summary: "", + transcriptCursor: 0, + }), + updateAgentContext: async () => {}, + }, + }, + sessions: { + resolve: async () => owner, + use: async () => ({ + authenticated: owner, + value: { ...repository, permissions: { push: true, admin: false } }, + }), + }, + }, + } as unknown as Chat.Room; + return { + chat, + context, + frames, + ...stream, + breakFrame: (kind: string) => brokenFrame = kind, + setOwnerAvailable: (value: boolean) => ownerAvailable = value, + }; +} diff --git a/apps/server/src/chat/job-heading-fencing.test.ts b/apps/server/src/chat/job-heading-fencing.test.ts new file mode 100644 index 00000000..a8402cce --- /dev/null +++ b/apps/server/src/chat/job-heading-fencing.test.ts @@ -0,0 +1,134 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, spyOn, test } from "bun:test"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import { headingMemory } from "./job-heading-memory.test-fixtures"; +import { socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +async function suspendHeading() { + let h = await headingMemory(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let restore = Room.restore; + let held = spyOn(Room, "restore").mockImplementation(async (...args) => { + let document = await restore(...args); + entered.resolve(); + await release.promise; + return document; + }); + await Chat.send(h.context, socket(), { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "room", + text: "Choose our authentication approach.", + ts: 0, + }); + await entered.promise; + return { h, release, held }; +} + +test("closing a held actual heading tool fences late document publication and restores interrupted work", async () => { + let { h, release, held } = await suspendHeading(); + try { + expect(h.plan.chat.job?.kind).toBe("heading"); + let closing = h.close(); + expect(h.plan.chat.job).toBeUndefined(); + release.resolve(); + await closing; + held.mockRestore(); + let saved = await h.saved(); + expect(saved.source.trim()).toBe(""); + expect(saved.sidecar.transcript).toHaveLength(1); + expect(h.opened.broadcasts.some(frame => frame.kind === "plan:update")).toBe(false); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened).trim()).toBe(""); + expect(reopened.conversationPlanJobs).toMatchObject([{ + status: "failed", + reason: "interrupted", + }]); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + release.resolve(); + held.mockRestore(); + await h.close(); + } +}); + +test("credential reset invalidates a held actual heading tool before its staged mutation can commit", async () => { + let { h, release, held } = await suspendHeading(); + try { + await Chat.resetAgent(h.plan.chat, h.sessionId, 1, "Planner credentials were revoked."); + expect(h.plan.chat.job).toBeUndefined(); + release.resolve(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "failed"); + await h.jobs.idle(); + await Plan.exclusive(h.plan, async () => {}); + held.mockRestore(); + let saved = await h.saved(); + expect(saved.source.trim()).toBe(""); + expect(saved.sidecar.conversationPlanJobs).toMatchObject([{ + status: "failed", + reason: "Planner credentials were revoked.", + }]); + expect(saved.sidecar.transcript).toHaveLength(1); + expect(h.plan.chat.jobOutput).toBeUndefined(); + expect(h.opened.broadcasts.some(frame => frame.kind === "plan:update")).toBe(false); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened).trim()).toBe(""); + expect(reopened.conversationPlanJobs[0]?.status).toBe("failed"); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + release.resolve(); + held.mockRestore(); + await h.close(); + } +}); + +test("active owner revocation fences a held actual heading tool without a Chat reset", async () => { + let { h, release, held } = await suspendHeading(); + try { + h.invalidateOwner(); + expect(h.plan.chat.job).toBeUndefined(); + release.resolve(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "failed"); + await h.jobs.idle(); + await Plan.exclusive(h.plan, async () => {}); + held.mockRestore(); + let saved = await h.saved(); + expect(saved.source.trim()).toBe(""); + expect(saved.sidecar.conversationPlanJobs).toMatchObject([{ + status: "failed", + reason: "credential-rotated", + }]); + expect(saved.sidecar.transcript).toHaveLength(1); + expect(h.plan.chat.jobOutput).toBeUndefined(); + expect(h.opened.broadcasts.some(frame => frame.kind === "plan:update")).toBe(false); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened).trim()).toBe(""); + expect(reopened.conversationPlanJobs[0]?.status).toBe("failed"); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + release.resolve(); + held.mockRestore(); + await h.close(); + } +}); diff --git a/apps/server/src/chat/job-heading-harness.test-fixtures.ts b/apps/server/src/chat/job-heading-harness.test-fixtures.ts new file mode 100644 index 00000000..77c31615 --- /dev/null +++ b/apps/server/src/chat/job-heading-harness.test-fixtures.ts @@ -0,0 +1,100 @@ +import type { HarnessV1 } from "@ai-sdk/harness"; + +/** Drives the current HarnessAgent tool dispatcher, so the real bound heading tool executes. */ +export function headingHarness( + onStart: () => Promise, + onResult: (output: unknown) => Promise, + call = { + name: "draft_heading", + input: { + revision: 0, + title: "Authentication", + goal: "Choose our authentication approach.", + } as Record, + }, +) { + let finishes: Array<() => void> = []; + let starts = 0; + let destroyed = 0; + let tools: string[][] = []; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "chat-heading-memory", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + starts++; + tools.push(options.tools.map(spec => spec.name)); + await onStart(); + let done = Promise.withResolvers(); + let finish = () => { + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + done.resolve(); + }; + finishes.push(finish); + options.abortSignal?.addEventListener("abort", finish, { once: true }); + void done.promise.then(() => options.abortSignal?.removeEventListener("abort", finish)); + queueMicrotask(() => + options.emit({ + type: "tool-call", + toolCallId: "heading", + toolName: call.name, + input: JSON.stringify(call.input), + }) + ); + return { + done: done.promise, + async submitToolResult(value) { + await onResult(value.output); + options.emit({ + type: "tool-result", + toolCallId: "heading", + toolName: call.name, + result: value.output as string, + }); + }, + }; + }, + async doDestroy() { + destroyed++; + }, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + for (let finish of finishes) finish(); + return { + type: "resume-session", + harnessId: "chat-heading-memory", + specificationVersion: "harness-v1", + data: null, + }; + }, + }; + }, + }; + return { + fake, + tools, + starts: () => starts, + destroyed: () => destroyed, + finish: () => finishes.at(-1)?.(), + }; +} diff --git a/apps/server/src/chat/job-heading-memory.test-fixtures.ts b/apps/server/src/chat/job-heading-memory.test-fixtures.ts new file mode 100644 index 00000000..e5640ec0 --- /dev/null +++ b/apps/server/src/chat/job-heading-memory.test-fixtures.ts @@ -0,0 +1,69 @@ +import * as Chat from "./service"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { interpretMessage } from "../conversation-plan/interpret"; +import { mockResult } from "../conversation-plan/interpret.test-fixtures"; +import { createConversationRuntime } from "../conversation-plan/runtime"; +import type { Room } from "../rooms"; + +export async function headingMemory() { + let opened = await openPlan(); + let plan = opened.plan; + let identity = await authenticatedMemory(opened); + let { context } = identity; + let errors: unknown[] = []; + let room: Room = { id: opened.channel.id, members: new Map(), plan }; + let runtime = createConversationRuntime({ + config: { + agent: true, + conversationPlan: true, + conversationPlanModel: "offline", + conversationPlanTimeoutMs: 1_000, + }, + server: () => opened.server, + unavailable: () => false, + interpret: input => + interpretMessage({ + ...input, + ask: async request => mockResult(request.questions, { enough_purpose: 0.9 }), + }), + onError: error => errors.push(error), + }); + await runtime.attach(room, plan, false); + runtime.bind(context); + let jobs = runtime.jobs(plan)!; + let processor = runtime.processor(plan)!; + let closing: Promise | undefined; + return { + opened, + plan, + jobs, + processor, + runtime, + room, + errors, + ...identity, + async saved() { + let saved = (await opened.storage.collaboration.load(opened.channel.id, opened.now))!; + return { + source: (await Plan.readStored(saved)).source, + sidecar: (saved.sidecar ?? saved.snapshot!.sidecar) as unknown as { + transcript: typeof plan.chat.entries; + conversationPlanJobs?: typeof plan.conversationPlanJobs; + conversationPlanEffects?: string[]; + conversationPlanPendingEffects?: unknown[]; + }, + }; + }, + close() { + if (closing) return closing; + let stopped = runtime.stop(plan); + Chat.cancelQueuedJobs(context, "The document closed."); + identity.revokeAll(); + closing = stopped.then(() => Plan.close(plan)); + room.closing = closing; + return closing; + }, + }; +} diff --git a/apps/server/src/chat/job-heading-memory.test.ts b/apps/server/src/chat/job-heading-memory.test.ts new file mode 100644 index 00000000..098f62f6 --- /dev/null +++ b/apps/server/src/chat/job-heading-memory.test.ts @@ -0,0 +1,75 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { HEADING_TOOL_NAMES } from "../harness/tool-names"; +import { headingMemory } from "./job-heading-memory.test-fixtures"; +import { socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("saved room message runs processor through current Harness and the actual durable heading tool", async () => { + let h = await headingMemory(); + let toolFinished = false; + let observedJobPending = false; + try { + h.onStart(async () => { + let stored = await h.saved(); + expect(stored.sidecar.conversationPlanEffects).toEqual(["job:heading:document"]); + expect(stored.sidecar.conversationPlanPendingEffects).toBeUndefined(); + expect(stored.sidecar.conversationPlanJobs).toMatchObject([{ status: "running" }]); + expect(stored.sidecar.transcript.map(entry => entry.text)).toEqual([ + "Choose our authentication approach.", + ]); + }); + h.onResult(async output => { + expect(String(output)).toContain('"ok": true'); + expect((await h.saved()).source).toContain("# Authentication"); + expect(h.plan.conversationPlanJobs[0]?.status).toBe("running"); + observedJobPending = true; + toolFinished = true; + }); + let id = crypto.randomUUID(); + await Chat.send(h.context, socket(), { + kind: "chat:send", + rid: "send", + requestId: id, + to: "room", + text: "Choose our authentication approach.", + ts: 0, + }); + await until(() => toolFinished); + expect(observedJobPending).toBe(true); + expect(h.plan.chat.entries).toHaveLength(1); + expect(h.plan.chat.jobOutput).toBe( + JSON.stringify({ title: "Authentication", goal: "Choose our authentication approach." }), + ); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.tools[0]).not.toContain("edit_plan"); + h.driver.finish(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "done"); + await h.jobs.idle(); + let saved = await h.saved(); + expect(saved.sidecar.conversationPlanJobs).toMatchObject([{ status: "done", attempts: 1 }]); + expect(saved.sidecar.transcript.map(entry => entry.text)).toEqual([ + "Choose our authentication approach.", + "Chopin drafted the title and goal", + ]); + expect(h.plan.chat.job).toBeUndefined(); + expect(h.driver.destroyed()).toBe(1); + expect(h.sandboxDestroys()).toBe(1); + expect(h.errors).toEqual([]); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened)).toContain("# Authentication"); + expect(reopened.conversationPlanJobs).toMatchObject([{ status: "done" }]); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/chat/job-lifecycle.test.ts b/apps/server/src/chat/job-lifecycle.test.ts new file mode 100644 index 00000000..46dbf450 --- /dev/null +++ b/apps/server/src/chat/job-lifecycle.test.ts @@ -0,0 +1,77 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("closing resolves queued and active jobs, and the queue has a bound", async () => { + let queued = harness(); + queued.chat.busy = true; + let waiting = Chat.job(queued.context, JOB, "prompt", "session"); + expect(queued.chat.waiting).toHaveLength(1); + await Chat.close(queued.chat); + expect(await waiting).toEqual({ status: "failed", reason: "The document closed." }); + expect(queued.chat.waiting).toEqual([]); + + let active = harness(); + let running = Chat.job(active.context, JOB, "prompt", "session"); + await until(() => active.prompts.length === 1); + await Chat.close(active.chat); + expect(await running).toEqual({ status: "failed", reason: "The document closed." }); + + let full = harness(); + full.chat.busy = true; + for (let index = 0; index < 20; index++) { + full.chat.waiting.push({ id: `w${index}`, handle: "ana", text: "human" }); + } + expect(await Chat.job(full.context, JOB, "prompt", "session")).toEqual({ + status: "skipped", + reason: "The Planner queue is full.", + }); + expect(full.chat.waiting).toHaveLength(20); +}); + +test("ephemeral state and queue broadcast failures cannot strand a job", async () => { + let direct = harness(); + direct.breakFrame("chat:state"); + let running = Chat.job(direct.context, JOB, "prompt", "session"); + await until(() => direct.prompts.length === 1); + await runJobTool(direct.chat, "refine_decision", async () => ({ output: {} })); + direct.emit("session.idle"); + expect(await running).toEqual({ status: "done", output: "{}" }); + + let queued = harness(); + queued.chat.busy = true; + queued.breakFrame("chat:queue"); + let waiting = Chat.job(queued.context, JOB, "prompt", "session"); + expect(queued.chat.waiting).toHaveLength(1); + await Chat.close(queued.chat); + expect(await waiting).toEqual({ status: "failed", reason: "The document closed." }); +}); + +test("a final persist failure settles the job and releases the queue", async () => { + let state = harness(); + state.context.persist = async () => { + throw new Error("storage unavailable"); + }; + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, { ...JOB, id: "refine:W2:m2" }, "later", "session"); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("session.idle"); + let outcome = await Promise.race([ + running, + Bun.sleep(300).then(() => { + throw new Error("job promise stranded by persist failure"); + }), + ]); + expect(outcome.status).toBe("failed"); + expect(await queued).toEqual({ + status: "failed", + reason: "The Planner turn could not be saved.", + }); + expect(state.chat.busy).toBe(false); + expect(state.chat.waiting).toEqual([]); +}); diff --git a/apps/server/src/chat/job-notice.test.ts b/apps/server/src/chat/job-notice.test.ts new file mode 100644 index 00000000..4dacd135 --- /dev/null +++ b/apps/server/src/chat/job-notice.test.ts @@ -0,0 +1,26 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { Chat as Wire } from "@chopin/protocol"; + +installJobCleanup(); + +test("a committed notice survives a failed post-commit broadcast", async () => { + let opened = await openPlan(); + opened.breakRelay("chat:message"); + try { + let entry = await Chat.notice({ + chat: opened.plan.chat, + plan: opened.plan, + server: opened.server, + room: opened.channel.id, + }, "The Planner refined this decision."); + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + let sidecar = stored?.sidecar ?? stored?.snapshot?.sidecar; + expect((sidecar as { transcript: Wire.Entry[] }).transcript).toContainEqual(entry); + } finally { + await Service.close(opened.plan); + } +}); diff --git a/apps/server/src/chat/job-profiles.test.ts b/apps/server/src/chat/job-profiles.test.ts new file mode 100644 index 00000000..b8d6cc21 --- /dev/null +++ b/apps/server/src/chat/job-profiles.test.ts @@ -0,0 +1,110 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { JOB_TOOLS, WRITE_TOOLS } from "../agent/job-scope"; +import { headingHarness } from "./job-heading-harness.test-fixtures"; +import { harness, JOB } from "./job.test-fixtures"; +import type { ActiveOwnerBinding } from "../agent/active-owner"; +import type { ConversationPlan } from "@chopin/protocol"; + +installJobCleanup(); + +test("each complete background profile opens the current session with only reads and its own writer", async () => { + for (let kind of ["heading", "refine", "suggest", "prose"] as ConversationPlan.JobKind[]) { + let opened = await openPlan(); + opened.plan.chat.job = { ...JOB, kind }; + let driver = headingHarness(async () => {}, async output => { + expect(String(output)).toContain('"revision": 0'); + driver.finish(); + }, { name: "read_plan", input: {} }); + let agent = createPlannerAgent(driver.fake, kind); + let owner: ActiveOwnerBinding = { + channelId: opened.channel.id, + token: "token", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + ownerSessionId: "owner", + ownerGeneration: 1, + credentialRevision: 1, + expiresAt: new Date(Date.now() + 60_000), + signal: new AbortController().signal, + currentToken: () => "token", + revalidate: async () => true, + release() {}, + }; + let result = await openPlannerSession(owner, { + room: Chat.documentRoom( + { + plan: opened.plan, + chat: opened.plan.chat, + room: opened.channel.id, + server: opened.server, + persist: () => Plan.persist(opened.plan), + } as Chat.Room, + ), + repository: owner.repository, + instructions: "Read the document.", + }, { + headingAgent: agent, + refineAgent: agent, + proseAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, + }) as never, + registerCredential: () => () => {}, + }); + try { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("complete profile did not open"); + expect(result.value.activeTools).toEqual(BACKGROUND_TOOL_NAMES[kind]); + expect(Object.isFrozen(result.value.activeTools)).toBe(true); + let stream = await result.value.stream("Read", owner.signal); + for await (let _part of stream.fullStream) {} + expect(driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES[kind]]]); + expect(driver.tools[0]!.filter(name => WRITE_TOOLS.has(name))).toEqual([JOB_TOOLS[kind]]); + await result.value.destroy(); + } finally { + opened.plan.chat.job = undefined; + await Plan.close(opened.plan); + } + } +}); + +test("the direct writer receives the same live verified member request", () => { + let h = harness(); + h.chat.busy = true; + h.chat.turn = { id: "turn", handle: "ana", started: 1, responded: false }; + let active: Chat.ActiveMemberRequest = { + entryId: "entry", + userId: "U_ana", + handle: "ana", + text: "Rename the auth decision.", + claimantSessionId: "session", + turnId: "turn", + lifecycle: h.chat.lifecycle, + }; + h.chat.entries.push({ + id: "entry", + author: { kind: "member", handle: "ana" }, + text: active.text, + ts: 1, + }); + h.chat.activeRequest = active; + let room = Chat.documentRoom(h.context); + expect(room.currentMemberRequest?.()).toBe(active); + h.chat.entries[0]!.text = "Changed source"; + expect(room.currentMemberRequest?.()).toBeUndefined(); + h.chat.entries[0]!.text = active.text; + h.chat.lifecycle++; + expect(room.currentMemberRequest?.()).toBeUndefined(); +}); diff --git a/apps/server/src/chat/job-prompt-card.test-fixtures.ts b/apps/server/src/chat/job-prompt-card.test-fixtures.ts new file mode 100644 index 00000000..de91c20b --- /dev/null +++ b/apps/server/src/chat/job-prompt-card.test-fixtures.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import type { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { PROMPT_FOR } from "../conversation-plan/job-prompts"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { ConversationPlan } from "@chopin/protocol"; + +type Intent = { kind: "suggest" | "prose"; target: string; trigger: string }; + +export async function promptCard( + opened: Awaited>, + intent: Intent, + script: unknown, +) { + let { kind, target, trigger } = intent; + let plan = opened.plan; + let dir = await mkdtemp(join(tmpdir(), "prompt-card-sdk-")); + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + let identity = await authenticatedMemory(opened, kind); + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if ( + !state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.questions) + ) throw new Error("missing durable questions"); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === target), + revision: state.revision, + }; + } + let observations: Array< + { + toolName: string; + output: unknown; + revision: number; + liveRevision: number | undefined; + saved: Awaited>; + } + > = []; + let driver = createPromptScriptedHarness(dir, { + onResult: async result => { + observations.push({ + toolName: result.toolName, + output: result.output, + revision: plan.revision, + liveRevision: Store.get(plan.questions, target)?.revision, + saved: await saved(), + }); + }, + }); + let agent = createPlannerAgent(driver.fake, kind); + let sessionId: string | undefined; + let setups: Array<{ command: string; directory: string }> = []; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + proseAgent: kind === "prose" ? agent : undefined, + refineAgent: kind === "suggest" ? agent : undefined, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups.length === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected prompt-scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let job: ConversationPlan.Job = { + id: `${kind}:${target}:${trigger}`, + kind, + target, + trigger, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + let prompt = PROMPT_FOR[kind]!(plan, job); + let closing: Promise | undefined; + return { + opened, + plan, + target, + identity, + driver, + results: () => observations, + context: identity.context, + job, + prompt, + saved, + counts: () => ({ + setups: setups.length, + unexpectedCommands, + sandboxDestroys, + credentialReleases, + }), + run: () => Chat.job(identity.context, job, prompt, identity.sessionId), + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await driver.fake.shutdown(); + await plan.chat.running; + await Plan.close(plan); + } finally { + await rm(dir, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-prompt-prose.test.ts b/apps/server/src/chat/job-prompt-prose.test.ts new file mode 100644 index 00000000..d015ecbb --- /dev/null +++ b/apps/server/src/chat/job-prompt-prose.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { proseIntent } from "../conversation-plan/prose-job"; +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import { CARD, OPTION, QUESTION } from "./job-card-memory.test-fixtures"; +import { promptCard } from "./job-prompt-card.test-fixtures"; + +const PARAGRAPH = "We chose GitHub Apps for authentication in the pilot."; + +async function answeredDocument() { + let record = Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + history: [{ choices: [OPTION], owner: "ana", at: 1_758_644_000 }], + }); + let widget = ` + + +\n`; + let opened = await openPlan(`Context.\n\n${widget}`, { questions: [record] }); + await Plan.close(opened.plan); + opened.plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + return opened; +} + +function anchoredParagraph(plan: Plan.Plan) { + let record = plan.records.get(CARD)!; + expect(record.prose).toHaveLength(1); + let anchor = record.prose![0]!; + expect(anchor.orphaned).not.toBe(true); + let matches = Room.digests(plan.document).map((_, index) => index).filter(index => + Room.matchesAnchor(plan.document, anchor, index) + ); + expect(matches).toHaveLength(1); + expect(Room.blockText(plan.document, matches)).toBe(PARAGRAPH); +} + +test("a prompt-only prose job rejects another card then writes its current saved generation durably", async () => { + let opened = await answeredDocument(); + let h: Awaited> | undefined; + try { + let baseline = structuredClone(opened.plan.records.get(CARD)!); + let source = Plan.source(opened.plan); + let revision = opened.plan.revision; + expect(baseline.status).toBe("answered"); + expect(Room.hasQuestionnaire(opened.plan.document, CARD, QUESTION)).toBe(true); + expect(decisionGeneration(baseline)).toBe(2); + let intent = proseIntent(baseline)!; + expect(intent).toEqual({ + kind: "prose", + target: CARD, + trigger: proseJobTrigger(CARD, decisionGeneration(baseline)), + }); + if (intent.kind !== "prose") throw new Error("expected the actual saved prose intent"); + h = await promptCard(opened, { + kind: intent.kind, + target: intent.target, + trigger: intent.trigger, + }, [ + { + tool: "write_decision_prose", + args: { revision: "$revision", id: "not-this-card", text: PARAGRAPH }, + }, + { + tool: "write_decision_prose", + args: { revision: "$revision", id: "$target", text: PARAGRAPH }, + }, + ]); + expect(h.job.trigger).toBe(intent.trigger); + expect(h.prompt).toStartWith("[Background job: prose]"); + expect(h.prompt).toContain(`Decision card id: ${CARD}`); + expect(h.prompt).toContain("Chosen: GitHub Apps"); + expect(await h.run()).toEqual({ + status: "done", + output: JSON.stringify({ title: "Which authentication approach?", mode: "insert" }), + }); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.prose]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "write_decision_prose", + "read_plan", + "write_decision_prose", + ]); + let observations = await h.results(); + expect(observations.map(item => item.toolName)).toEqual([ + "read_plan", + "write_decision_prose", + "read_plan", + "write_decision_prose", + ]); + let [firstRead, rejected, renewedRead, accepted] = observations; + expect(rejected!.output).toBe("Error: This job writes up a different decision."); + expect(rejected!.saved.record).toEqual(baseline); + expect(rejected!.saved.source).toBe(source); + expect(rejected!.revision).toBe(revision); + expect(rejected!.saved.revision).toBe(revision); + let firstRevision = JSON.parse(String(firstRead!.output)).revision; + let renewedRevision = JSON.parse(String(renewedRead!.output)).revision; + expect(firstRevision).toBe(revision); + expect(renewedRevision).toBe(revision); + expect(JSON.parse(String(renewedRead!.output)).source).toBe(source); + expect(h.driver.calls[1]!.input).toEqual({ + revision: firstRevision, + id: "not-this-card", + text: PARAGRAPH, + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision: renewedRevision, + id: CARD, + text: PARAGRAPH, + }); + expect(String(accepted!.output)).toContain('"ok": true'); + let completed = structuredClone(opened.plan.records.get(CARD)!); + expect(accepted!.saved.record).toEqual(completed); + expect(accepted!.saved.source).toBe(Plan.source(opened.plan)); + expect(accepted!.saved.source.split(PARAGRAPH)).toHaveLength(2); + expect(accepted!.saved.revision).toBe(opened.plan.revision); + expect(accepted!.revision).toBeGreaterThan(revision); + expect(completed.definition).toEqual(baseline.definition); + expect(completed.choices).toEqual(baseline.choices); + expect(proseIntent(completed)).toEqual(intent); + anchoredParagraph(opened.plan); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(opened.plan.chat.job).toBeUndefined(); + expect(opened.plan.chat.turn).toBeUndefined(); + expect(opened.plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe( + false, + ); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ).toBe(false); + let completedSource = Plan.source(opened.plan); + await h.close(); + let reopened = await Plan.open(opened.channel.id, opened.backend, opened.server); + try { + expect(reopened.records.get(CARD)).toEqual(completed); + expect(Plan.source(reopened)).toBe(completedSource); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + anchoredParagraph(reopened); + } finally { + await Plan.close(reopened); + } + } finally { + if (h) await h.close(); + else if (!opened.plan.chat.closed) await Plan.close(opened.plan); + } +}); diff --git a/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts b/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts new file mode 100644 index 00000000..29de6c9d --- /dev/null +++ b/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { PROMPT_FOR } from "../conversation-plan/job-prompts"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function promptScripted(kind: "heading" | "refine", script: unknown) { + let opened = await openPlan(kind === "heading" ? "\n" : "Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let target = kind === "heading" ? "document" : await Questions.insertConversationCard( + plan, + opened.server, + opened.channel.id, + { + threadId: "prompt-thread", + header: "Authentication", + question: "What auth should we use?", + options: [], + }, + ); + plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "Choose authentication for a small pilot.", + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + opened.plan = plan; + let dir = await mkdtemp(join(tmpdir(), "prompt-scripted-sdk-")); + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + let identity = await authenticatedMemory(opened, kind); + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if ( + !state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.questions) + ) throw new Error("missing durable questions"); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === target), + revision: state.revision, + }; + } + let observations: Array< + { + toolName: string; + output: unknown; + revision: number; + saved: Awaited>; + } + > = []; + let driver = createPromptScriptedHarness(dir, { + onResult: async result => { + observations.push({ + toolName: result.toolName, + output: result.output, + revision: plan.revision, + saved: await saved(), + }); + }, + }); + let agent = createPlannerAgent(driver.fake, kind); + let sessionId: string | undefined; + let setups: Array<{ command: string; directory: string }> = []; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + headingAgent: kind === "heading" ? agent : undefined, + refineAgent: kind === "refine" ? agent : undefined, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups.length === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected prompt-scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let job: ConversationPlan.Job = { + id: `${kind}:${target}:m1`, + kind, + target, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + let prompt = PROMPT_FOR[kind]!(plan, job); + let closing: Promise | undefined; + return { + opened, + plan, + target, + identity, + driver, + observations, + job, + prompt, + saved, + counts: () => ({ + setups: setups.length, + unexpectedCommands, + sandboxDestroys, + credentialReleases, + }), + run: () => Chat.job(identity.context, job, prompt, identity.sessionId), + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await driver.fake.shutdown(); + await plan.chat.running; + await Plan.close(plan); + } finally { + await rm(dir, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-prompt-scripted-harness.test.ts b/apps/server/src/chat/job-prompt-scripted-harness.test.ts new file mode 100644 index 00000000..e28dce48 --- /dev/null +++ b/apps/server/src/chat/job-prompt-scripted-harness.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { promptScripted } from "./job-prompt-scripted-harness.test-fixtures"; + +function result(output: unknown): { revision: number; source?: string; ok?: boolean } { + return JSON.parse(String(output)); +} + +function cleanup(h: Awaited>) { + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(h.plan.chat.job).toBeUndefined(); + expect(h.plan.chat.turn).toBeUndefined(); + expect(h.plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + h.opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ).toBe(false); +} + +test("a prompt-only heading model reads the real revision before its durable own-tool write", async () => { + let h = await promptScripted("heading", [{ + tool: "draft_heading", + args: { + revision: "$revision", + title: "Auth Strategy", + goal: "Goal: choose authentication for the pilot.", + }, + }]); + try { + expect(h.prompt).toStartWith("[Background job: heading]"); + expect(h.prompt).toContain("Choose authentication for a small pilot."); + expect((await h.run()).status).toBe("done"); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.heading]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan", "draft_heading"]); + expect(h.observations.map(item => item.toolName)).toEqual(["read_plan", "draft_heading"]); + let [read, write] = h.observations; + expect(h.driver.calls[0]!.input).toEqual({}); + expect(h.driver.calls[1]!.input).toMatchObject({ revision: result(read!.output).revision }); + expect(result(read!.output).revision).toBe(read!.revision); + expect(result(read!.output).source).toBe(read!.saved.source); + expect(write!.saved.source).toBe( + "# Auth Strategy\n\nGoal: choose authentication for the pilot.\n", + ); + expect(write!.saved.revision).toBe(h.plan.revision); + expect(write!.revision).toBeGreaterThan(read!.revision); + cleanup(h); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened)).toBe(write!.saved.source); + expect(reopened.chat.entries).toHaveLength(1); + expect(reopened.chat.waiting).toEqual([]); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); + +test("a prompt-only refine model renews read_plan between two actual card writes", async () => { + let title = "Which authentication approach fits the pilot?"; + let rationale = "It keeps the pilot small and concrete."; + let h = await promptScripted("refine", [ + { + tool: "refine_decision", + args: { revision: "$revision", id: "$target", title, add_options: [] }, + }, + { + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + add_options: [{ label: "GitHub Apps", rationale }], + }, + }, + ]); + try { + expect(h.prompt).toStartWith("[Background job: refine]"); + expect(h.prompt).toContain(`Decision card ${h.target}:`); + let initialRevision = h.plan.revision; + expect((await h.run()).status).toBe("done"); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + expect(h.observations.map(item => item.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let [firstRead, firstWrite, secondRead, secondWrite] = h.observations; + let firstRevision = result(firstRead!.output).revision; + let secondRevision = result(secondRead!.output).revision; + expect(firstRevision).toBe(initialRevision); + expect(firstRevision).toBe(firstRead!.revision); + expect(secondRevision).toBe(firstWrite!.revision); + expect(secondRevision).toBe(secondRead!.revision); + expect(secondRevision).toBeGreaterThan(firstRevision); + expect(h.driver.calls[1]!.input).toEqual({ + revision: firstRevision, + id: h.target, + title, + add_options: [], + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision: secondRevision, + id: h.target, + add_options: [{ label: "GitHub Apps", rationale }], + }); + expect(firstWrite!.saved.record!.definition.questions[0]!.question).toBe(title); + expect(firstWrite!.saved.record!.definition.questions[0]!.options).toEqual([]); + expect(result(secondRead!.output).source).toBe(firstWrite!.saved.source); + let record = structuredClone(h.plan.records.get(h.target)!); + let option = record.definition.questions[0]!.options[0]!; + expect(record.definition.questions[0]!.options).toHaveLength(1); + expect(option.label).toBe("GitHub Apps"); + expect(option.id).toBeTruthy(); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale }); + expect(secondWrite!.saved.record).toEqual(record); + expect(secondWrite!.saved.source).toBe(Plan.source(h.plan)); + expect(secondWrite!.saved.revision).toBe(secondWrite!.revision); + expect(secondWrite!.revision).toBeGreaterThan(secondRevision); + cleanup(h); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(reopened.records.get(h.target)).toEqual(record); + expect(Plan.source(reopened)).toBe(secondWrite!.saved.source); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/chat/job-prompt-suggest.test.ts b/apps/server/src/chat/job-prompt-suggest.test.ts new file mode 100644 index 00000000..645ca853 --- /dev/null +++ b/apps/server/src/chat/job-prompt-suggest.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { promptCard } from "./job-prompt-card.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; + +test("a prompt-only suggest model refuses a title change before adding its sourced option", async () => { + let opened = await openPlan("Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let h: Awaited> | undefined; + try { + let id = await Questions.insertConversationCard(plan, opened.server, opened.channel.id, { + threadId: "suggest-thread", + header: "Authentication", + question: "What auth should we use?", + options: [], + }); + let source: ConversationPlan.SourceRef = { + messageId: "m-option", + author: { kind: "member", handle: "ana" }, + quote: "GitHub Apps", + start: 0, + end: 11, + role: "option", + }; + plan.chat.entries.push({ + id: source.messageId, + author: source.author, + text: source.quote, + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + opened.plan = plan; + await Plan.persist(plan); + let baseline = structuredClone(plan.records.get(id)!); + let initialSource = Plan.source(plan); + let revision = plan.revision; + let draft = Store.get(plan.questions, id)!; + let draftRevision = draft.revision; + let rationale = "It keeps the pilot small and concrete."; + h = await promptCard(opened, { kind: "suggest", target: id, trigger: source.messageId }, [ + { + tool: "refine_decision", + args: { revision: "$revision", id: "$target", title: "Forbidden title?", add_options: [] }, + }, + { + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + add_options: [{ label: source.quote, rationale, source }], + }, + }, + ]); + expect(h.prompt).toStartWith("[Background job: suggest]"); + expect(h.prompt).toContain(`Decision card ${id}:`); + let outcome = await h.run(); + expect(outcome).toEqual({ + status: "done", + output: JSON.stringify({ title: baseline.definition.questions[0]!.question, added: 1 }), + }); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.suggest]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let observations = h.results(); + expect(observations.map(result => result.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let [firstRead, refused, secondRead, added] = observations; + expect(refused!.output).toBe("Error: title and place_after are only changed by a refine job."); + expect(refused!.saved.record).toEqual(baseline); + expect(refused!.saved.source).toBe(initialSource); + expect(refused!.revision).toBe(revision); + expect(refused!.liveRevision).toBe(draftRevision); + expect(refused!.saved.revision).toBe(revision); + expect(JSON.parse(String(firstRead!.output)).revision).toBe(revision); + expect(JSON.parse(String(secondRead!.output)).revision).toBe(revision); + expect(secondRead!.saved.record).toEqual(baseline); + expect(secondRead!.saved.source).toBe(initialSource); + expect(h.driver.calls[1]!.input).toEqual({ + revision, + id, + title: "Forbidden title?", + add_options: [], + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision, + id, + add_options: [{ label: source.quote, rationale, source }], + }); + let record = structuredClone(plan.records.get(id)!); + let question = record.definition.questions[0]!; + expect(question.question).toBe(baseline.definition.questions[0]!.question); + expect(question.options).toHaveLength(1); + let option = question.options[0]!; + expect(option.label).toBe(source.quote); + expect(option.id).toBeTruthy(); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale, source }); + expect(added!.saved.record).toEqual(record); + expect(added!.saved.source).toBe(Plan.source(plan)); + expect(added!.revision).toBeGreaterThan(revision); + expect(added!.saved.revision).toBe(added!.revision); + expect(String(added!.output)).toContain('"ok": true'); + expect(Store.get(plan.questions, id)!.revision).toBe(draftRevision + 1); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.identity.driver.starts()).toBe(0); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); + expect(plan.chat.entries).toHaveLength(1); + expect(plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ) + .toBe(false); + let savedSource = Plan.source(plan); + await h.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + expect(plan.records.get(id)).toEqual(record); + expect(Plan.source(plan)).toBe(savedSource); + expect(plan.chat.entries).toHaveLength(1); + expect(plan.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await h?.close(); + if (!plan.chat.closed) await Plan.close(plan); + } +}); diff --git a/apps/server/src/chat/job-queue.test.ts b/apps/server/src/chat/job-queue.test.ts new file mode 100644 index 00000000..5712f52e --- /dev/null +++ b/apps/server/src/chat/job-queue.test.ts @@ -0,0 +1,83 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("jobs and human turns drain in arrival order without leaking job scope", async () => { + let first = harness(); + await Chat.send(first.context, socket(), member("Human first")); + await until(() => first.prompts.length === 1); + let behind = Chat.job(first.context, JOB, "job second", "session"); + expect(first.chat.waiting).toHaveLength(1); + first.emit("session.idle"); + await until(() => first.prompts.length === 2); + expect(first.prompts[1]).toBe("job second"); + await runJobTool(first.chat, "refine_decision", async () => ({ output: { added: 0 } })); + first.emit("session.idle"); + expect(await behind).toEqual({ status: "done", output: '{"added":0}' }); + + let second = harness(); + let ahead = Chat.job(second.context, JOB, "job first", "session"); + await until(() => second.prompts.length === 1); + await Chat.send(second.context, socket(), member("Human second")); + expect(second.chat.waiting).toHaveLength(1); + await runJobTool(second.chat, "refine_decision", async () => ({ output: { added: 0 } })); + second.emit("session.idle"); + expect(await ahead).toEqual({ status: "done", output: '{"added":0}' }); + await until(() => second.prompts.length === 2); + expect(second.chat.job).toBeUndefined(); + expect(second.prompts[1]).toContain("Human second"); + second.emit("session.idle"); + await until(() => !second.chat.busy); +}); + +test("archiving cancels a job queued behind a human turn without dropping human work", async () => { + let state = harness(); + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "job later", "session"); + state.chat.waiting.push({ id: "human-later", handle: "ana", text: "Human later" }); + expect(state.chat.waiting).toHaveLength(2); + Chat.cancelQueuedJobs(state.context, "This document was archived."); + expect(await queued).toEqual({ + status: "failed", + reason: "This document was archived.", + }); + expect(state.chat.waiting.map(item => item.id)).toEqual(["human-later"]); + expect(state.frames.at(-1)).toMatchObject({ kind: "chat:queue" }); + state.emit("session.idle"); + await until(() => state.prompts.length === 2); + expect(state.prompts).not.toContain("job later"); + state.emit("session.idle"); + await until(() => !state.chat.busy); +}); + +test("archive reset fences a job shifted out of the queue during persistence", async () => { + let state = harness(); + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "job later", "session"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let saves = 0; + state.context.persist = async () => { + if (++saves === 2) { + entered.resolve(); + await release.promise; + } + }; + state.emit("session.idle"); + await entered.promise; + expect(state.chat.waiting).toEqual([]); + Chat.cancelQueuedJobs(state.context, "This document was archived."); + await Chat.resetAgent(state.chat, undefined, undefined, "This document was archived."); + release.resolve(); + expect(await queued).toEqual({ + status: "failed", + reason: "This document was archived.", + }); + expect(state.prompts).toHaveLength(1); +}); diff --git a/apps/server/src/chat/job-queue.ts b/apps/server/src/chat/job-queue.ts new file mode 100644 index 00000000..d7bd689c --- /dev/null +++ b/apps/server/src/chat/job-queue.ts @@ -0,0 +1,152 @@ +import { ulid } from "@chopin/dialect"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type { JobOutcome } from "../conversation-plan/jobs"; +import type { Announcer } from "./notices"; +import type { Chat, Room, Waiting } from "./service"; + +export type JobTurn = { + job: ConversationPlan.Job; + prompt: string; + done: (outcome: JobOutcome) => void; + settled?: boolean; +}; + +export function jobReason(error: unknown): string { + let message = error instanceof Error ? error.message : String(error); + return message.trim().slice(0, 500) || "The Planner could not be reached."; +} + +export function finishJob(turn: JobTurn, outcome: JobOutcome): void { + if (turn.settled) return; + turn.settled = true; + turn.done(outcome); +} + +export function safeProjection(project: (...args: Args) => void) { + return (...args: Args): void => { + try { + project(...args); + } catch (error) { + console.error("[chat] could not broadcast background job state:", error); + } + }; +} + +export function createJobQueue({ queued, state, startRun, MAX_QUEUE }: { + MAX_QUEUE: number; + queued: (chat: Chat, server: Server, room: string) => void; + state: (chat: Chat, server: Server, room: string) => void; + startRun: ( + context: Room, + handle: string, + text: string, + thread: string | undefined, + claimantSessionId: string, + reserved?: boolean, + member?: never, + references?: [], + jobTurn?: JobTurn, + ) => void; +}) { + function cancelQueuedJobs(context: Announcer, reason: string): number { + let { chat, server, room } = context; + let retained: Waiting[] = []; + let cancelled = 0; + for (let waiting of chat.waiting) { + if (!waiting.job) { + retained.push(waiting); + continue; + } + cancelled++; + finishJob(waiting.job, { status: "failed", reason: jobReason(reason) }); + } + if (chat.handoffJob && !chat.handoffJob.settled) { + cancelled++; + finishJob(chat.handoffJob, { status: "failed", reason: jobReason(reason) }); + } + if (cancelled > 0) { + chat.waiting = retained; + queued(chat, server, room); + } + return cancelled; + } + + function stopAfterPersistenceFailure(chat: Chat, server: Server, room: string): void { + chat.closed = true; + chat.busy = false; + chat.turn = undefined; + chat.acting = undefined; + let waiting = chat.waiting; + chat.waiting = []; + for (let item of waiting) { + if (item.job) { + finishJob(item.job, { + status: "failed", + reason: "The Planner turn could not be saved.", + }); + } + } + queued(chat, server, room); + state(chat, server, room); + } + + function job( + context: Room, + value: ConversationPlan.Job, + prompt: string, + claimantSessionId: string, + signal?: AbortSignal, + ): Promise { + let { chat, config, room, server } = context; + if (!config.agent) { + return Promise.resolve({ status: "skipped", reason: "The Planner is off (AGENT=off)." }); + } + if (chat.closed) return Promise.resolve({ status: "failed", reason: "The document closed." }); + let cancelled: JobOutcome = { status: "failed", reason: "The Planner job was cancelled." }; + if (signal?.aborted) return Promise.resolve(cancelled); + let { promise, resolve } = Promise.withResolvers(); + let turn: JobTurn = { + job: value, + prompt, + done: outcome => { + signal?.removeEventListener("abort", cancel); + resolve(outcome); + }, + }; + function cancel() { + if (turn.settled) return; + if (chat.job === value) { + chat.interruption = "The Planner job was cancelled."; + chat.job = undefined; + chat.jobOutput = undefined; + chat.turnController?.abort(); + return; + } + chat.waiting = chat.waiting.filter(item => item.job !== turn); + finishJob(turn, cancelled); + queued(chat, server, room); + } + signal?.addEventListener("abort", cancel, { once: true }); + if (chat.busy) { + if (chat.waiting.length >= MAX_QUEUE) { + finishJob(turn, { status: "skipped", reason: "The Planner queue is full." }); + return promise; + } + chat.waiting.push({ + id: ulid(), + handle: "chopin", + text: "(background job)", + sessionId: claimantSessionId, + job: turn, + }); + queued(chat, server, room); + } else { + startRun(context, "chopin", prompt, undefined, claimantSessionId, false, undefined, [], turn); + } + return promise; + } + + return { job, cancelQueuedJobs, stopAfterPersistenceFailure }; +} diff --git a/apps/server/src/chat/job-scripted-harness.test-fixtures.ts b/apps/server/src/chat/job-scripted-harness.test-fixtures.ts new file mode 100644 index 00000000..73a8331d --- /dev/null +++ b/apps/server/src/chat/job-scripted-harness.test-fixtures.ts @@ -0,0 +1,122 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { createScriptedHarness } from "../conversation-plan/scripted-harness.test-driver"; +import type { ConversationPlan } from "@chopin/protocol"; + +export function headingJob(): ConversationPlan.Job { + return { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +export async function scriptedHeading(script: unknown, held = false) { + let opened = await openPlan("\n"); + let path = await mkdtemp(join(tmpdir(), "current-scripted-harness-")); + await writeFile(join(path, "heading.json"), JSON.stringify(script)); + if (held) await writeFile(join(path, "heading.hold"), "hold"); + let identity = await authenticatedMemory(opened); + let resultCheck: ((output: unknown) => Promise) | undefined; + let driver = createScriptedHarness(path, opened.plan, { + onResult: async result => { + await resultCheck?.(result.output); + }, + }); + let agent = createPlannerAgent(driver.fake, "heading"); + let setupRequests: Array<{ command: string; directory: string }> = []; + let unexpectedSandboxRuns = 0; + let plannerSessionId: string | undefined; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + headingAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(plannerSessionId ?? "") + }`; + if ( + plannerSessionId && setupRequests.length === 0 + && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 + && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setupRequests.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedSandboxRuns++; + throw new Error("scripted Harness must not execute unexpected sandbox commands"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: sessionId => { + plannerSessionId = sessionId; + return () => { + credentialReleases++; + }; + }, + }); + let closing: Promise | undefined; + return { + opened, + identity, + driver, + context: identity.context, + onResult(check: (output: unknown) => Promise) { + resultCheck = check; + }, + setupRequests, + plannerSessionId: () => plannerSessionId, + counts: () => ({ + setupRequests: setupRequests.length, + unexpectedSandboxRuns, + sandboxDestroys, + credentialReleases, + }), + async savedSource() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + return (await Plan.readStored(stored)).source; + }, + run(signal?: AbortSignal) { + return Chat.job( + identity.context, + headingJob(), + "Draft the title and goal", + identity.sessionId, + signal, + ); + }, + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await Plan.close(opened.plan); + } finally { + await rm(path, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-scripted-harness.test.ts b/apps/server/src/chat/job-scripted-harness.test.ts new file mode 100644 index 00000000..7810d74f --- /dev/null +++ b/apps/server/src/chat/job-scripted-harness.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { HEADING_TOOL_NAMES } from "../harness/tool-names"; +import { scriptedHeading } from "./job-scripted-harness.test-fixtures"; + +let HEADING = { + tool: "draft_heading", + args: { revision: "$revision", title: "Auth Strategy", goal: "Goal: pick auth." }, +}; +let SOURCE = "# Auth Strategy\n\nGoal: pick auth.\n"; + +test("a script runs through current Harness and commits the real heading before its result", async () => { + let h = await scriptedHeading([HEADING]); + let observed = false; + try { + h.onResult(async output => { + expect(String(output)).toContain('"ok": true'); + expect(await h.savedSource()).toBe(SOURCE); + expect(h.opened.plan.chat.job?.kind).toBe("heading"); + observed = true; + }); + expect(await h.run()).toEqual({ + status: "done", + output: JSON.stringify({ title: "Auth Strategy", goal: "Goal: pick auth." }), + }); + expect(observed).toBe(true); + expect(h.plannerSessionId()).toBeDefined(); + expect(h.setupRequests).toEqual([{ + command: 'mkdir -p "$WORK_DIR"', + directory: `/tmp/${encodeURIComponent(h.driver.fake.harnessId)}-${ + encodeURIComponent(h.plannerSessionId()!) + }`, + }]); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + expect(h.driver.results).toHaveLength(1); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened)).toBe(SOURCE); + expect(reopened.chat.entries).toEqual([]); + expect(reopened.chat.waiting).toEqual([]); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); + +test("an inactive scripted write aborts the current stream and prevents a heading mutation", async () => { + let h = await scriptedHeading([ + { + tool: "edit_plan", + args: { revision: "$revision", operations: [{ op: "insert_root", source: "x\n" }] }, + }, + HEADING, + ]); + try { + expect(await h.run()).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: edit_plan", + }); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls[0]?.toolName).toBe("edit_plan"); + expect(h.driver.results).toMatchObject([{ + toolName: "edit_plan", + output: { type: "execution-denied" }, + }]); + expect(h.driver.results.some(result => result.toolName === "draft_heading")).toBe(false); + expect(await h.savedSource()).toBe(""); + expect(Plan.source(h.opened.plan)).toBe(""); + expect(h.opened.broadcasts.some(frame => frame.kind === "plan:update")).toBe(false); + expect(h.opened.plan.chat.entries).toEqual([]); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + } finally { + await h.close(); + } +}); + +test("aborting a held current Harness script drains its session without a document write", async () => { + let h = await scriptedHeading([HEADING], true); + let controller = new AbortController(); + try { + let running = h.run(controller.signal); + await h.driver.entered; + expect(h.opened.plan.chat.job?.id).toBe("heading:document:m0"); + expect(h.opened.plan.chat.turn).toBeDefined(); + expect(h.driver.calls).toEqual([]); + controller.abort(); + let outcome = await running; + expect(outcome.status).toBe("failed"); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(await h.savedSource()).toBe(""); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + expect(h.opened.plan.chat.busy).toBe(false); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + } finally { + controller.abort(); + await h.close(); + } +}); + +test("abort waits for a held result observer before draining the current Harness session", async () => { + let h = await scriptedHeading([HEADING, HEADING]); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let controller = new AbortController(); + let settled = false; + let observerFinished = false; + try { + h.onResult(async () => { + expect(await h.savedSource()).toBe(SOURCE); + entered.resolve(); + await release.promise; + observerFinished = true; + }); + let running = h.run(controller.signal); + void running.then(() => { + settled = true; + }); + await entered.promise; + controller.abort(); + await Bun.sleep(10); + expect(settled).toBe(false); + expect(observerFinished).toBe(false); + expect(h.driver.destroyed()).toBe(0); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + release.resolve(); + expect((await running).status).toBe("failed"); + expect(settled).toBe(true); + expect(observerFinished).toBe(true); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + expect(h.driver.results).toHaveLength(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(await h.savedSource()).toBe(SOURCE); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + } finally { + controller.abort(); + release.resolve(); + await h.close(); + } +}); diff --git a/apps/server/src/chat/job-signal.test.ts b/apps/server/src/chat/job-signal.test.ts new file mode 100644 index 00000000..defb9e08 --- /dev/null +++ b/apps/server/src/chat/job-signal.test.ts @@ -0,0 +1,56 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("the coordinator signal cancels only its queued job and retains human work", async () => { + let h = harness(); + h.chat.busy = true; + let signal = new AbortController(); + let queued = Chat.job(h.context, JOB, "prompt", "session", signal.signal); + h.chat.waiting.push({ id: "human", handle: "ana", text: "Human later" }); + signal.abort(); + expect(await queued).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + expect(h.chat.waiting.map(item => item.id)).toEqual(["human"]); + expect(h.prompts).toEqual([]); +}); + +test("the coordinator signal fences active own-tool scope before settling the turn", async () => { + let h = harness(); + let signal = new AbortController(); + let running = Chat.job(h.context, JOB, "prompt", "session", signal.signal); + await until(() => h.prompts.length === 1); + signal.abort(); + expect(h.chat.job).toBeUndefined(); + expect(await running).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + expect(h.chat.entries).toEqual([]); + expect(h.chat.busy).toBe(false); +}); + +test("the coordinator signal settles a shifted job before a held handoff commit finishes", async () => { + let h = harness(); + await Chat.send(h.context, socket(), member("Human first")); + await until(() => h.prompts.length === 1); + let signal = new AbortController(); + let running = Chat.job(h.context, JOB, "later", "session", signal.signal); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let saves = 0; + h.context.persist = async () => { + if (++saves === 2) { + entered.resolve(); + await release.promise; + } + }; + h.emit("session.idle"); + await entered.promise; + expect(h.chat.waiting).toEqual([]); + signal.abort(); + expect(await running).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + release.resolve(); + await until(() => !h.chat.busy); + expect(h.prompts).toHaveLength(1); + expect(h.chat.handoffJob).toBeUndefined(); +}); diff --git a/apps/server/src/chat/job-stream.test-fixtures.ts b/apps/server/src/chat/job-stream.test-fixtures.ts new file mode 100644 index 00000000..4f42e934 --- /dev/null +++ b/apps/server/src/chat/job-stream.test-fixtures.ts @@ -0,0 +1,109 @@ +import type { TextStreamPart, ToolSet } from "ai"; +import type { PlannerSession } from "../harness/session"; + +type Part = TextStreamPart; + +/** Legacy test commands feed the current typed stream, without a Copilot SDK session. */ +export function jobStream() { + let prompts: string[] = []; + let parts: Part[] = []; + let wake = Promise.withResolvers(); + let ended = false; + let error: Error | undefined; + let aborts = 0; + function push(part: Part) { + parts.push(part); + wake.resolve(); + } + function end() { + ended = true; + wake.resolve(); + } + function emit(type: string, data: Record = {}) { + switch (type) { + case "assistant.message_delta": + push({ type: "text-delta", id: String(data.messageId), text: String(data.deltaContent) }); + return; + case "assistant.message": + push({ type: "text-end", id: String(data.messageId) }); + return; + case "tool.execution_start": + push({ + type: "tool-call", + toolCallId: String(data.toolCallId), + toolName: String(data.toolName), + input: {}, + }); + return; + case "tool.execution_complete": { + let content = (data.result as { content?: unknown } | undefined)?.content; + push( + data.success + ? { + type: "tool-result", + toolCallId: String(data.toolCallId), + toolName: "refine_decision", + input: {}, + output: content, + } + : { + type: "tool-error", + toolCallId: String(data.toolCallId), + toolName: "refine_decision", + input: {}, + error: content, + }, + ); + return; + } + case "session.error": + push({ type: "error", error: new Error(String(data.message)) }); + end(); + return; + case "session.idle": + end(); + } + } + function session(): PlannerSession { + return { + activeTools: ["read_plan", "refine_decision"], + async stream(prompt, signal) { + prompts.push(prompt); + if (error) throw error; + parts = []; + ended = false; + wake = Promise.withResolvers(); + let stop = () => { + aborts++; + end(); + }; + signal.addEventListener("abort", stop, { once: true }); + if (signal.aborted) stop(); + let fullStream = (async function*() { + try { + while (true) { + while (parts.length) yield parts.shift()!; + if (ended) return; + await wake.promise; + wake = Promise.withResolvers(); + } + } finally { + signal.removeEventListener("abort", stop); + } + })(); + return { fullStream } as never; + }, + async destroy() { + end(); + }, + }; + } + return { + prompts, + emit, + emitPart: push, + session, + aborts: () => aborts, + setSendError: (value: Error) => error = value, + }; +} diff --git a/apps/server/src/chat/job-tools.test.ts b/apps/server/src/chat/job-tools.test.ts new file mode 100644 index 00000000..afd99897 --- /dev/null +++ b/apps/server/src/chat/job-tools.test.ts @@ -0,0 +1,96 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("resetting an interrupted session fails its active job without a transcript line", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + await Chat.resetAgent(state.chat, "session", 1, "Planner credentials were revoked."); + expect(await running).toEqual({ + status: "failed", + reason: "Planner credentials were revoked.", + }); + expect(state.chat.entries).toEqual([]); +}); + +test("two failed own-tool calls end the turn after one possible re-read", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + for (let index = 0; index < 2; index++) { + state.emit("tool.execution_start", { + toolCallId: `tool-${index}`, + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: `tool-${index}`, + success: true, + result: { content: "Error: stale revision; read_plan again" }, + }); + if (index === 0) expect(state.chat.job).toBe(JOB); + } + let outcome = await running; + expect(outcome.status).toBe("failed"); + if (outcome.status === "done") throw new Error("two tool failures completed the job"); + expect(outcome.reason).toContain("failed twice"); + expect(state.aborts()).toBe(1); + expect(state.chat.job).toBeUndefined(); +}); + +test("a bounded source-shape tool failure is reported without echoing its arguments", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + state.emit("tool.execution_start", { + toolCallId: "malformed-source", + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: "malformed-source", + success: true, + result: { + content: "Error: source-shape", + arguments: { source: { quote: "private malformed quote" } }, + }, + }); + state.emit("session.idle"); + + let outcome = await running; + expect(outcome).toEqual({ status: "failed", reason: "source-shape" }); + if (outcome.status === "done") throw new Error("a malformed source completed the job"); + expect(outcome.reason).not.toContain("private malformed quote"); + expect(outcome.reason).not.toContain("arguments"); +}); + +test("a successful retry clears a prior source-shape tool failure", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + state.emit("tool.execution_start", { + toolCallId: "malformed-source", + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: "malformed-source", + success: true, + result: { content: "Error: source-shape" }, + }); + state.emit("tool.execution_start", { + toolCallId: "valid-source", + toolName: "refine_decision", + }); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("tool.execution_complete", { + toolCallId: "valid-source", + success: true, + result: { content: '{"ok":true}' }, + }); + state.emit("session.idle"); + + expect(await running).toEqual({ status: "done", output: '{"added":1}' }); +}); diff --git a/apps/server/src/chat/job-translate.ts b/apps/server/src/chat/job-translate.ts new file mode 100644 index 00000000..8f1a5799 --- /dev/null +++ b/apps/server/src/chat/job-translate.ts @@ -0,0 +1,67 @@ +import { JOB_TOOLS, WRITE_TOOLS } from "../agent/job-scope"; +import type { TextStreamPart, ToolSet } from "ai"; +import type { Room } from "./service"; + +/** Background stream content stays private; only the live own-tool wrapper records output. */ +export function translateJob(context: Room, part: TextStreamPart): void { + let { chat } = context; + let job = chat.job; + if (!job) return; + let own = JOB_TOOLS[job.kind]; + function interrupt(reason: string) { + chat.interruption ??= reason; + chat.turnController?.abort(); + } + switch (part.type) { + case "tool-call": { + if ( + !chat.jobToolNames?.has(part.toolName) + || WRITE_TOOLS.has(part.toolName) && part.toolName !== own + || chat.jobCalls?.has(part.toolCallId) + ) { + console.error(`[chat] boundary failure: inactive job tool ${part.toolName}`); + interrupt(`Planner tool boundary failure: ${part.toolName}`); + return; + } + chat.jobCalls?.set(part.toolCallId, part.toolName); + return; + } + case "tool-result": + case "tool-error": + case "tool-output-denied": { + let name = chat.jobCalls?.get(part.toolCallId); + chat.jobCalls?.delete(part.toolCallId); + if (!name || "toolName" in part && part.toolName !== name) { + interrupt("Planner tool boundary failure: unrecognized job tool result"); + return; + } + if (part.type === "tool-output-denied") { + interrupt("Planner tool permission was denied."); + return; + } + if (name !== own) return; + let content = part.type === "tool-result" ? part.output : part.error; + chat.jobFailureCode = name === "refine_decision" && part.type === "tool-result" + && content === "Error: source-shape" + ? "source-shape" + : undefined; + if ( + part.type === "tool-error" || typeof content === "string" && content.startsWith("Error:") + ) { + chat.jobFailures = (chat.jobFailures ?? 0) + 1; + if (chat.jobFailures >= 2) { + interrupt(chat.jobFailureCode ?? `The Planner's ${own} failed twice.`); + } + } + return; + } + case "tool-approval-request": + interrupt("Planner tool approval was denied."); + return; + case "error": + interrupt(part.error instanceof Error ? part.error.message : String(part.error)); + return; + default: + return; + } +} diff --git a/apps/server/src/chat/job.test-fixtures.ts b/apps/server/src/chat/job.test-fixtures.ts new file mode 100644 index 00000000..55af6331 --- /dev/null +++ b/apps/server/src/chat/job.test-fixtures.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, spyOn } from "bun:test"; +import * as Chat from "./service"; +import { harness as currentHarness } from "./job-harness.test-fixtures"; +import type { Chat as Wire, ConversationPlan, Request } from "@chopin/protocol"; +import type { Socket } from "../wire"; + +export const JOB: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", +}; + +let chats: Chat.Chat[] = []; +let errors: ReturnType | undefined; + +export function installJobCleanup(): void { + beforeEach(() => { + errors = spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + for (let chat of chats) await Chat.close(chat); + chats = []; + errors?.mockRestore(); + errors = undefined; + }); +} + +export 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"); +} + +export function member(text: string): Request { + return { + kind: "chat:send", + rid: crypto.randomUUID(), + requestId: crypto.randomUUID(), + to: "planner", + text, + ts: 0, + }; +} + +export function socket(): Socket { + return { data: { handle: "ana", principalId: "U_ana" }, send() {} } as unknown as Socket; +} + +export function harness(agent = true) { + return currentHarness(agent, chats); +} diff --git a/apps/server/src/chat/job.test.ts b/apps/server/src/chat/job.test.ts new file mode 100644 index 00000000..d5dc8131 --- /dev/null +++ b/apps/server/src/chat/job.test.ts @@ -0,0 +1,95 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("job skips disabled and unavailable Planner sessions without transcript entries", async () => { + let off = harness(false); + expect(await Chat.job(off.context, JOB, "exact prompt", "session")).toEqual({ + status: "skipped", + reason: "The Planner is off (AGENT=off).", + }); + expect(off.chat.entries).toEqual([]); + let missing = harness(); + missing.setOwnerAvailable(false); + let outcome = await Chat.job(missing.context, JOB, "exact prompt", "session"); + expect(outcome.status).toBe("skipped"); + if (outcome.status === "done") throw new Error("an unavailable owner completed a job"); + expect(outcome.reason).toContain("owner is unavailable"); + expect(missing.chat.entries).toEqual([]); + expect(missing.chat.busy).toBe(false); + let credentials = harness(); + (credentials.context.auth.sessions as never as { resolve: () => Promise }).resolve = + async () => { + throw new Error("credential expired"); + }; + expect(await Chat.job(credentials.context, JOB, "prompt", "session")).toEqual({ + status: "skipped", + reason: "credential expired", + }); +}); + +test("job sends its prompt verbatim and keeps backscroll while suppressing model chatter", async () => { + let state = harness(); + state.chat.backscroll = [{ handle: "ana", text: "Human context" }]; + let running = Chat.job(state.context, JOB, "EXACT job prompt\nsecond line", "session"); + await until(() => state.prompts.length === 1); + expect(state.prompts).toEqual(["EXACT job prompt\nsecond line"]); + expect(state.chat.turn?.handle).toBe("chopin"); + expect(state.chat.job).toBe(JOB); + state.emit("assistant.message_delta", { messageId: "m", deltaContent: "private thought" }); + state.emit("assistant.message", { messageId: "m", content: "private thought" }); + state.emit("tool.execution_start", { toolCallId: "tool", toolName: "refine_decision" }); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("tool.execution_complete", { + toolCallId: "tool", + success: true, + result: { content: '{"ok":true}' }, + }); + state.emit("session.idle"); + expect(await running).toEqual({ status: "done", output: '{"added":1}' }); + expect(state.chat.backscroll).toEqual([{ handle: "ana", text: "Human context" }]); + expect(state.chat.entries).toEqual([]); + expect(state.frames.some(frame => frame.kind === "chat:message" || frame.kind === "chat:tool")) + .toBe(false); + expect(state.chat.job).toBeUndefined(); + expect(state.chat.jobOutput).toBeUndefined(); + expect(state.chat.busy).toBe(false); +}); + +test("job fails on SDK send error, session error after tool output, and a tool-less idle", async () => { + let send = harness(); + send.setSendError(new Error("send unavailable")); + expect(await Chat.job(send.context, JOB, "prompt", "session")).toEqual({ + status: "failed", + reason: "send unavailable", + }); + expect(send.chat.entries).toEqual([]); + + let crashed = harness(); + let work = Chat.job(crashed.context, JOB, "prompt", "session"); + await until(() => crashed.prompts.length === 1); + await runJobTool(crashed.chat, "refine_decision", async () => ({ output: { added: 1 } })); + crashed.emit("session.error", { message: "model failed after the write" }); + expect(await work).toEqual({ status: "failed", reason: "model failed after the write" }); + expect(crashed.chat.entries).toEqual([]); + + let idle = harness(); + let empty = Chat.job(idle.context, JOB, "prompt", "session"); + await until(() => idle.prompts.length === 1); + idle.emit("session.idle"); + expect(await empty).toEqual({ + status: "failed", + reason: "The Planner ended without calling refine_decision.", + }); + + let long = harness(); + long.setSendError(new Error("x".repeat(600))); + let bounded = await Chat.job(long.context, JOB, "prompt", "session"); + expect(bounded.status).toBe("failed"); + if (bounded.status === "done") throw new Error("a failed send completed the job"); + expect(bounded.reason).toHaveLength(500); +}); diff --git a/apps/server/src/chat/notice-idempotency.test.ts b/apps/server/src/chat/notice-idempotency.test.ts new file mode 100644 index 00000000..6f682a49 --- /dev/null +++ b/apps/server/src/chat/notice-idempotency.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { hosted } from "./notice.test-fixtures"; + +test("a stable notice is committed and broadcast only once across concurrent retries", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + let result = await original(input); + context.order.push("committed"); + return result; + }; + let id = "research-complete:request-1"; + let [first, second] = await Promise.all([ + Chat.noticeOnce(context.announcer, id, "Research is ready"), + Chat.noticeOnce(context.announcer, id, "Research is ready"), + ]); + expect(second).toBe(first); + expect(first.id).toBe(id); + expect(commits).toBe(1); + expect(context.order).toEqual(["committed", "broadcast:Research is ready"]); + expect(context.plan.chat.entries).toEqual([first]); + await Service.close(context.plan); +}); + +test("a stable notice retry after restart returns its durable entry without publication", async () => { + let context = await hosted(); + let id = "research-complete:request-2"; + let input = { + text: "Research is ready", + decision: { questionnaireId: "document", kind: "activity" as const, label: "Ready" }, + }; + let first = await Chat.noticeOnce(context.announcer, id, input); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + let announcer = { ...context.announcer, chat: restored.chat, plan: restored }; + context.order.length = 0; + let retry = await Chat.noticeOnce(announcer, id, input); + expect(retry).toBe(restored.chat.entries[0]); + expect(retry).toEqual(first); + expect(context.order).toEqual([]); + await Service.close(restored); +}); + +test("a stable notice rejects divergent content without changing the transcript", async () => { + let context = await hosted(); + let id = "research-complete:request-3"; + let first = await Chat.noticeOnce(context.announcer, id, "Research is ready"); + context.order.length = 0; + await expect(Chat.noticeOnce(context.announcer, id, "Research failed")) + .rejects.toThrow("conflicts with an existing entry"); + await expect(Chat.noticeOnce(context.announcer, id, { + text: "Research is ready", + decision: { questionnaireId: "document", kind: "activity" }, + })).rejects.toThrow("conflicts with an existing entry"); + expect(context.plan.chat.entries).toEqual([first]); + expect(context.order).toEqual([]); + await Service.close(context.plan); +}); + +test("a failed stable notice commit can be retried with the same ID", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + let attempts = 0; + context.storage.collaboration.commit = async input => { + attempts++; + if (attempts === 1) throw new Error("disk"); + return original(input); + }; + let id = "research-complete:request-4"; + await expect(Chat.noticeOnce(context.announcer, id, "Research is ready")) + .rejects.toThrow("disk"); + expect(context.plan.chat.entries).toEqual([]); + expect(context.order).toEqual([]); + let entry = await Chat.noticeOnce(context.announcer, id, "Research is ready"); + expect(entry.id).toBe(id); + expect(context.plan.chat.entries).toEqual([entry]); + expect(context.order).toEqual(["broadcast:Research is ready"]); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice-persistence.test.ts b/apps/server/src/chat/notice-persistence.test.ts new file mode 100644 index 00000000..15a2ce1e --- /dev/null +++ b/apps/server/src/chat/notice-persistence.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import type { Socket } from "../wire"; +import { hosted } from "./notice.test-fixtures"; + +test("a decision notice commits before broadcasting and restores", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + let result = await original(input); + context.order.push("committed"); + return result; + }; + let entry = await Chat.notice(context.announcer, { + text: "Ready to decide: What auth system should we use?", + decision: { questionnaireId: ulid(), kind: "prompt", generation: 0 }, + }); + expect(context.order).toEqual([ + "committed", + "broadcast:Ready to decide: What auth system should we use?", + ]); + expect(entry).toMatchObject({ author: { kind: "system" }, decision: { kind: "prompt" } }); + expect(context.plan.chat.entries.at(-1)).toBe(entry); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries.at(-1)?.decision).toEqual(entry.decision); + await Service.close(restored); +}); + +test("a refreshed notice keeps its ID, commits before broadcast, and rolls back on failure", async () => { + let context = await hosted(); + let questionnaireId = ulid(); + let first = await Chat.notice(context.announcer, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Initial" }, + }); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + let result = await original(input); + context.order.push("committed"); + return result; + }; + let refreshed = await Service.exclusive( + context.plan, + () => + Chat.refreshNoticeExclusive(context.announcer, first.id, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Updated" }, + }), + ); + expect(refreshed.id).toBe(first.id); + expect(context.plan.chat.entries).toHaveLength(1); + expect(context.order.slice(-2)).toEqual(["committed", "broadcast:Ready to decide"]); + expect(context.frames.at(-1)?.entry?.decision).toEqual(refreshed.decision); + + let broadcasts = context.frames.length; + context.storage.collaboration.commit = async () => { + throw new Error("disk"); + }; + await expect( + Service.exclusive(context.plan, () => + Chat.refreshNoticeExclusive(context.announcer, first.id, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Failed" }, + })), + ).rejects.toThrow("disk"); + expect(context.plan.chat.entries[0]).toBe(refreshed); + expect(context.frames).toHaveLength(broadcasts); + context.storage.collaboration.commit = original; + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries).toHaveLength(1); + expect(restored.chat.entries[0]?.decision).toEqual(refreshed.decision); + await Service.close(restored); +}); + +test("a failed notice commit cannot be saved by a following transcript write", async () => { + let context = await hosted(); + let entered: (() => void) | undefined; + let started = new Promise(resolve => entered = resolve); + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + let original = context.storage.collaboration.commit; + let first = true; + context.storage.collaboration.commit = async input => { + if (first) { + first = false; + entered!(); + await blocked; + throw new Error("disk"); + } + return original(input); + }; + let failed = Chat.notice(context.announcer, "failed notice"); + await started; + let socket = { + data: { handle: "octocat", principalId: "U_octocat" }, + send() {}, + } as unknown as Socket; + let later = Chat.send( + { + ...context.announcer, + persist: () => Service.persist(context.plan), + } as Chat.Room, + socket, + { + kind: "chat:send", + ts: 0, + rid: "member-send", + requestId: crypto.randomUUID(), + text: "Keep this message", + to: "room", + }, + ); + await Bun.sleep(0); + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual([ + "failed notice", + "Keep this message", + ]); + release!(); + await expect(failed).rejects.toThrow("disk"); + await later; + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual(["Keep this message"]); + expect(context.order).toEqual(["broadcast:Keep this message"]); + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = loaded?.sidecar ?? loaded?.snapshot?.sidecar; + expect(JSON.stringify(sidecar)).not.toContain("failed notice"); + expect(JSON.stringify(sidecar)).toContain("Keep this message"); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice-publication.test.ts b/apps/server/src/chat/notice-publication.test.ts new file mode 100644 index 00000000..868574f2 --- /dev/null +++ b/apps/server/src/chat/notice-publication.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { hosted } from "./notice.test-fixtures"; + +test("an activity can refer to the document heading", async () => { + let context = await hosted(); + let entry = await Chat.notice(context.announcer, { + text: "Chopin drafted the title and goal", + decision: { questionnaireId: "document", kind: "activity" }, + }); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries.at(-1)?.decision).toEqual(entry.decision); + await Service.close(restored); +}); + +test("a notice broadcasts the decision metadata that was committed", async () => { + let context = await hosted(); + let entered: (() => void) | undefined; + let started = new Promise(resolve => entered = resolve); + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + entered!(); + await blocked; + return original(input); + }; + let decision = { questionnaireId: ulid(), kind: "activity" as const, label: "Original" }; + let pending = Chat.notice(context.announcer, { text: "Refined Original", decision }); + await started; + decision.label = "Changed before broadcast"; + release!(); + let entry = await pending; + expect(entry.decision?.label).toBe("Original"); + expect(context.frames.findLast(frame => frame.kind === "chat:message")?.entry?.decision) + .toEqual(entry.decision); + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = loaded?.sidecar ?? loaded?.snapshot?.sidecar; + expect(JSON.stringify(sidecar)).toContain('"label":"Original"'); + expect(JSON.stringify(sidecar)).not.toContain("Changed before broadcast"); + await Service.close(context.plan); +}); + +test("concurrent notices broadcast in commit order", async () => { + let context = await hosted(); + let first = Chat.notice(context.announcer, "first"); + let second = Chat.notice(context.announcer, "second"); + await Promise.all([first, second]); + expect(context.order).toEqual(["broadcast:first", "broadcast:second"]); + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual(["first", "second"]); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice.test-fixtures.ts b/apps/server/src/chat/notice.test-fixtures.ts new file mode 100644 index 00000000..af68dcdf --- /dev/null +++ b/apps/server/src/chat/notice.test-fixtures.ts @@ -0,0 +1,39 @@ +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; + +export async function hosted() { + let now = new Date("2026-08-13T12:00:00.000Z"); + let storage = new MemoryStorage(); + await storage.users.put({ id: "U_octocat", login: "octocat", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Release", + createdBy: "U_octocat", + now, + }); + let lease = await storage.leases.acquire("writer", "test", 60_000); + if (!lease) throw new Error("could not acquire test lease"); + let order: string[] = []; + let frames: Array<{ kind: string; entry?: { text: string; decision?: unknown } }> = []; + let server = { + publish(_topic: string, data: string) { + let frame = JSON.parse(data) as { kind: string; entry?: { text: string } }; + frames.push(frame); + if (frame.kind === "chat:message") order.push(`broadcast:${frame.entry?.text}`); + }, + } as unknown as Server; + let backend: Service.Backend = { + storage, + lease: () => lease, + fatal: () => {}, + }; + let plan = await Service.open(channel.id, backend, server); + let announcer: Chat.Announcer = { chat: plan.chat, plan, server, room: channel.id }; + return { storage, channel, lease, server, plan, announcer, order, frames, now }; +} diff --git a/apps/server/src/chat/notices.ts b/apps/server/src/chat/notices.ts new file mode 100644 index 00000000..a84eb8f7 --- /dev/null +++ b/apps/server/src/chat/notices.ts @@ -0,0 +1,125 @@ +import { isDeepStrictEqual } from "node:util"; +import { ulid } from "@chopin/dialect"; +import * as Service from "../plan/service"; +import type { Chat as Wire } from "@chopin/protocol"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type { Room } from "./service"; + +export type Announcer = Pick; +export type NoticeInput = string | { text: string; decision?: NonNullable }; + +export function createNotices({ now, announce }: { + now: () => number; + announce: (server: Server, room: string, entry: Wire.Entry) => void; +}) { + function notice(context: Announcer, input: NoticeInput): Promise { + return Service.exclusive(context.plan, () => noticeExclusive(context, input)); + } + + function noticeOnce( + context: Announcer, + id: string, + input: NoticeInput, + ): Promise { + if (!id) return Promise.reject(new Error("chat notice ID is required")); + return Service.exclusive(context.plan, async () => { + let existing = context.chat.entries.filter(entry => entry.id === id); + if (existing.length > 0) { + let entry = existing[0]; + let { text, decision } = typeof input === "string" + ? { text: input, decision: undefined } + : input; + if ( + existing.length !== 1 + || entry.author.kind !== "system" + || entry.text !== text + || Object.hasOwn(entry, "streaming") + || Object.hasOwn(entry, "tools") + || Object.hasOwn(entry, "references") + || entry.decision?.questionnaireId !== decision?.questionnaireId + || entry.decision?.kind !== decision?.kind + || entry.decision?.generation !== decision?.generation + || entry.decision?.label !== decision?.label + || !isDeepStrictEqual(entry.decision, decision) + ) throw new Error(`chat notice ${id} conflicts with an existing entry`); + return entry; + } + return appendNoticeExclusive(context, input, id); + }); + } + + function noticeExclusive(context: Announcer, input: NoticeInput): Promise { + return appendNoticeExclusive(context, input, ulid()); + } + + async function refreshNoticeExclusive( + context: Announcer, + id: string, + input: { text: string; decision: NonNullable }, + ): Promise { + let matches = context.chat.entries.filter(entry => entry.id === id); + let previous = matches[0]; + if ( + matches.length !== 1 || !previous || previous.author.kind !== "system" + || (previous.decision?.kind !== input.decision.kind + && !(previous.decision?.kind === "scoped-choice" && input.decision.kind === "activity") + && !(previous.decision?.kind === "prompt" && input.decision.kind === "activity")) + || previous.decision.questionnaireId !== input.decision.questionnaireId + || Object.hasOwn(previous, "streaming") || Object.hasOwn(previous, "tools") + || Object.hasOwn(previous, "references") + ) throw new Error("chat notice cannot be refreshed"); + let index = context.chat.entries.indexOf(previous); + let entry: Wire.Entry = { + ...previous, + text: input.text, + decision: structuredClone(input.decision), + }; + context.chat.entries[index] = entry; + try { + await Service.persistExclusive(context.plan); + } catch (error) { + context.chat.entries[index] = previous; + throw error; + } + try { + announce(context.server, context.room, entry); + } catch (error) { + console.error("[chat] could not broadcast a persisted notice refresh:", error); + } + return entry; + } + + async function appendNoticeExclusive( + context: Announcer, + input: NoticeInput, + id: string, + ): Promise { + let { chat, room, server } = context; + let { text, decision } = typeof input === "string" + ? { text: input, decision: undefined } + : input; + let entry: Wire.Entry = { + id, + author: { kind: "system" }, + text, + ts: now(), + ...(decision ? { decision: { ...decision } } : {}), + }; + chat.entries.push(entry); + try { + await Service.persistExclusive(context.plan); + } catch (error) { + chat.entries = chat.entries.filter(value => value !== entry); + throw error; + } + try { + announce(server, room, entry); + } catch (error) { + console.error("[chat] could not broadcast a persisted notice:", error); + } + return entry; + } + + return { notice, noticeOnce, noticeExclusive, refreshNoticeExclusive }; +} diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 8a9cc96b..e2019298 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -29,11 +29,18 @@ import * as Service from "../plan/service"; import { instruction } from "@chopin/protocol/address"; import { annotatedText, compose, referenceCatalog, remember } from "./address"; +import { createNotices } from "./notices"; +import { createJobQueue, finishJob, jobReason, type JobTurn, safeProjection } from "./job-queue"; +import { translateJob } from "./job-translate"; +import { watchJobAbort } from "./job-fence"; +import { JOB_TOOLS } from "../agent/job-scope"; +import type { JobOutcome } from "../conversation-plan/jobs"; +export type { Announcer, NoticeInput } from "./notices"; import { broadcast, fail, reply, tell } from "../wire"; import type { Server } from "bun"; import type { TextStreamPart, ToolSet } from "ai"; -import type { Chat as Wire, Request } from "@chopin/protocol"; +import type { Chat as Wire, ConversationPlan, Request } from "@chopin/protocol"; import type { Config } from "../config"; import type { HostedAuth } from "../auth/routes"; import type { HostedRepository } from "../agent/repository"; @@ -71,7 +78,7 @@ const ACTIVE_TOOLS = new Set(PLANNER_TOOL_NAMES); * is only knowable at that moment — and `JSON.stringify` drops it, so the wire * shape stays exactly `Wire.Waiting`. */ -type Waiting = Wire.Waiting & { +export type Waiting = Wire.Waiting & { delivery?: Delivery; spent?: () => boolean; /** True when this came from the composer rather than another instruction. */ @@ -82,6 +89,8 @@ type Waiting = Wire.Waiting & { sessionId?: string; /** Verified member identity, retained only for a queued composer message. */ userId?: string; + /** A queued background turn, resolved only after its scope is cleaned up. */ + job?: JobTurn; }; export type ActiveMemberRequest = { @@ -103,6 +112,14 @@ export type Instruction = { }; export type Chat = { + job?: ConversationPlan.Job; + jobOutput?: string; + jobFailures?: number; + jobFailureCode?: "source-shape"; + jobCalls?: Map; + jobToolNames?: ReadonlySet; + /** The job shifted from the queue while its handoff save is pending. */ + handoffJob?: JobTurn; entries: Wire.Entry[]; waiting: Waiting[]; /** Serializes complete member send acceptance, including asynchronous resolution and persistence. */ @@ -405,6 +422,8 @@ export type Room = { repository: HostedRepository; activeOwner?: () => Promise; persist: () => Promise; + commitRoomMessage?: (entry: Wire.Entry) => Promise; + roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; ownerAvailable?: () => Promise; jobs?: JobService; @@ -500,15 +519,19 @@ async function processSend(context: Room, ws: Socket, msg: Request): ...(references?.length ? { references } : {}), delivery: savedDelivery, }; - chat.entries.push(entry); + if (!context.commitRoomMessage) chat.entries.push(entry); try { - await context.persist(); + if (context.commitRoomMessage) await context.commitRoomMessage(entry); + else await context.persist(); } catch { - chat.entries = chat.entries.filter(value => value.id !== entry.id); + if (!context.commitRoomMessage) { + chat.entries = chat.entries.filter(value => value.id !== entry.id); + } return fail(ws, msg.rid, "could not save message"); } reply(ws, msg.rid, { kind: "chat:send", ts: 0, id: entry.id, queued: false }); announce(server, room, entry); + context.roomMessagePublished?.(); chat.backscroll = remember(chat.backscroll, { entryId: entry.id, handle, @@ -606,7 +629,12 @@ async function processSend(context: Room, ws: Socket, msg: Request): } /** Say something in the transcript without asking the agent for anything. */ -export function notice(context: Room, text: string): void | Promise { +export let { notice, noticeOnce, noticeExclusive, refreshNoticeExclusive } = createNotices({ + now, + announce, +}); + +function instructionNotice(context: Room, text: string): void | Promise { let { chat, room, server } = context; let entry: Wire.Entry = { id: ulid(), author: { kind: "system" }, text, ts: now() }; chat.entries.push(entry); @@ -667,15 +695,25 @@ export function instruct( startRun(context, handle, text, about.thread, context.claimantSessionId); }; - let announced = notice(context, said); + let announced = instructionNotice(context, said); return announced instanceof Promise ? announced.then(proceed) : proceed(); } +let jobState = safeProjection(state); +let jobQueued = safeProjection(queued); +let { job, cancelQueuedJobs, stopAfterPersistenceFailure } = createJobQueue({ + queued: jobQueued, + state: jobState, + startRun, + MAX_QUEUE, +}); +export { cancelQueuedJobs, job }; + /** Withdraw a queued message. Only whoever wrote it may. */ export function unqueue(context: Room, ws: Socket, msg: Request): void { let { chat, room, server } = context; let found = chat.waiting.find(item => item.id === msg.id); - if (!found || found.handle !== ws.data.handle) return; + if (!found || found.job || found.handle !== ws.data.handle) return; chat.waiting = chat.waiting.filter(item => item.id !== msg.id); queued(chat, server, room); } @@ -719,6 +757,7 @@ export function documentRoom(context: Room): DocumentRoom { anchors: () => Service.anchors(plan, server, room), changes: found => Service.changes(plan, server, room, found), jobs: context.jobs, + currentMemberRequest: () => currentMemberRequest(chat), readReference: async (id, repositoryId) => { let reference = chat.referenceCache.get(id); if (!reference) throw new Error("reference is not available in this Planner session"); @@ -907,7 +946,8 @@ async function repositorySession( ) throw new Error("The Planner session changed while opening. Try again."); chat.agent = opened; chat.owner = openingOwner; - consumeBootstrapBackscroll(chat); + if (!chat.job) consumeBootstrapBackscroll(chat); + else chat.bootstrapEntries = undefined; return { session: opened, binding }; } catch (err) { await opened?.destroy(); @@ -985,15 +1025,19 @@ async function run( reserved = false, member?: MemberRequest, references: Wire.Reference[] = [], + jobTurn?: JobTurn, ): Promise { let { chat, plan, room, server } = context; - if (chat.closed) return; + if (chat.closed) { + if (jobTurn) finishJob(jobTurn, { status: "failed", reason: "The document closed." }); + return; + } if (!reserved) { chat.busy = true; chat.turn = { id: ulid(), handle, started: now(), responded: false }; chat.acting = thread; - state(chat, server, room); + (jobTurn ? jobState : state)(chat, server, room); } else chat.acting = thread; chat.activeRequest = member && chat.turn @@ -1008,6 +1052,17 @@ async function run( } : undefined; chat.messageIds = new Map(); + if (jobTurn) { + chat.job = jobTurn.job; + chat.jobOutput = undefined; + chat.jobFailures = 0; + chat.jobFailureCode = undefined; + chat.jobCalls = new Map(); + } + let sendStarted = false; + let outcome: JobOutcome | undefined; + let persistenceError: unknown; + let releaseJobAbort: (() => void) | undefined; let opened: { session: PlannerSession; binding: ActiveOwnerBinding } | undefined; let turnController = new AbortController(); @@ -1017,30 +1072,55 @@ async function run( if (chat.agent !== opened.session || turnController.signal.aborted) { throw new Error("The Planner session changed before the turn started. Try again."); } - // Drained rather than copied: what the agent has been told once should - // not arrive again on the next turn. - let backscroll = chat.backscroll; - chat.backscroll = []; - let promptReferences = [ - ...backscroll.flatMap(said => said.references ?? []), - ...references, - ]; - retainReferences(chat, promptReferences); - let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); - let prompt = compose(backscroll, handle, text, references, available); - - let result = await opened.session.stream( - prompt, - AbortSignal.any([opened.binding.signal, turnController.signal]), - ); + chat.jobToolNames = jobTurn + ? new Set(opened.session.activeTools ?? PLANNER_TOOL_NAMES) + : undefined; + let prompt: string; + if (jobTurn) { + prompt = jobTurn.prompt; + } else { + // Drained rather than copied: what the agent has been told once should + // not arrive again on the next turn. + let backscroll = chat.backscroll; + chat.backscroll = []; + let promptReferences = [ + ...backscroll.flatMap(said => said.references ?? []), + ...references, + ]; + retainReferences(chat, promptReferences); + let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); + prompt = compose(backscroll, handle, text, references, available); + } + sendStarted = true; + let signal = AbortSignal.any([opened.binding.signal, turnController.signal]); + if (jobTurn) releaseJobAbort = watchJobAbort(chat, jobTurn, signal); + let result = await opened.session.stream(prompt, signal); for await (let part of result.fullStream) { translate(context, part); if (turnController.signal.aborted) break; } + if (jobTurn && chat.closed) throw new Error("The document closed."); if (chat.interruption) throw new Error(chat.interruption); + if (jobTurn && turnController.signal.aborted) throw new Error("The Planner turn was stopped."); + if (jobTurn) { + outcome = chat.jobOutput === undefined + ? { + status: "failed", + reason: chat.jobFailureCode + ?? `The Planner ended without calling ${JOB_TOOLS[jobTurn.job.kind]}.`, + } + : { status: "done", output: chat.jobOutput }; + } } catch (err) { console.error("[chat] turn failed:", err); - if (!chat.closed) { + if (jobTurn) { + outcome = chat.closed + ? { status: "failed", reason: "The document closed." } + : { + status: sendStarted || chat.interruption ? "failed" : "skipped", + reason: jobReason(err), + }; + } else if (!chat.closed) { say(chat, server, room, { id: ulid(), author: { kind: "system" }, @@ -1050,10 +1130,18 @@ async function run( } chat.interruption = undefined; } finally { + releaseJobAbort?.(); chat.activeRequest = undefined; chat.turnController = undefined; try { - await opened?.session.destroy(); + if (jobTurn) { + try { + await opened?.session.destroy(); + } catch (err) { + persistenceError = err; + console.error("[chat] could not close a finished turn:", err); + } + } else await opened?.session.destroy(); } finally { opened?.binding.release(); if (chat.agent === opened?.session) chat.agent = undefined; @@ -1063,7 +1151,28 @@ async function run( chat.writing = undefined; chat.tooling = undefined; settle(chat, server, room); - if (!chat.closed) await context.persist(); + if (jobTurn) { + chat.job = undefined; + chat.jobOutput = undefined; + chat.jobFailures = undefined; + chat.jobFailureCode = undefined; + chat.jobCalls = undefined; + } + chat.jobToolNames = undefined; + try { + if (!chat.closed) await context.persist(); + } catch (err) { + persistenceError = err; + console.error("[chat] could not save a finished turn:", err); + } + if (jobTurn) { + if (chat.closed) outcome = { status: "failed", reason: "The document closed." }; + else if (persistenceError) { + outcome = { status: "failed", reason: jobReason(persistenceError) }; + } else if (chat.interruption) { + outcome = { status: "failed", reason: jobReason(chat.interruption) }; + } + } /* * The agent's cursor outlives the turn by a moment. @@ -1083,13 +1192,38 @@ async function run( } } + if (persistenceError) { + stopAfterPersistenceFailure(chat, server, room); + } + if (jobTurn) { + finishJob(jobTurn, outcome ?? { status: "failed", reason: "The Planner turn ended." }); + } + if (chat.closed) return; let next = pending(chat); - if (next) { - await context.persist(); - if (chat.closed) return; - queued(chat, server, room); - let entry = next.message ? chat.entries.find(item => item.id === next.id) : undefined; + while (next) { + if (next.job) chat.handoffJob = next.job; + try { + await context.persist(); + } catch (err) { + if (chat.handoffJob === next.job) chat.handoffJob = undefined; + console.error("[chat] could not save a queued turn:", err); + if (next.job) finishJob(next.job, { status: "failed", reason: jobReason(err) }); + stopAfterPersistenceFailure(chat, server, room); + return; + } + if (chat.handoffJob === next.job) chat.handoffJob = undefined; + if (chat.closed) { + if (next.job) finishJob(next.job, { status: "failed", reason: "The document closed." }); + return; + } + if (next.job?.settled) { + next = pending(chat); + continue; + } + (next.job ? jobQueued : queued)(chat, server, room); + let nextId = next.id; + let entry = next.message ? chat.entries.find(item => item.id === nextId) : undefined; if (entry) announce(server, room, entry); await run( context, @@ -1100,13 +1234,14 @@ async function run( false, next.message && next.userId ? { entryId: next.id, userId: next.userId } : undefined, next.references, + next.job, ); - } else { - chat.busy = false; - chat.turn = undefined; - chat.acting = undefined; - state(chat, server, room); + return; } + chat.busy = false; + chat.turn = undefined; + chat.acting = undefined; + (jobTurn ? jobState : state)(chat, server, room); } function startRun( @@ -1118,9 +1253,23 @@ function startRun( reserved = false, member?: MemberRequest, references?: Wire.Reference[], + jobTurn?: JobTurn, ): void { - if (context.chat.closed) return; - let running = run(context, handle, text, thread, claimantSessionId, reserved, member, references); + if (context.chat.closed) { + if (jobTurn) finishJob(jobTurn, { status: "failed", reason: "The document closed." }); + return; + } + let running = run( + context, + handle, + text, + thread, + claimantSessionId, + reserved, + member, + references ?? [], + jobTurn, + ); context.chat.running = running; void running.finally(() => { if (context.chat.running === running) context.chat.running = undefined; @@ -1155,6 +1304,7 @@ export function pending(chat: Chat): Waiting | undefined { /** Project one AI SDK stream part into the shared Conversation. */ export function translate(context: Room, part: TextStreamPart): void { let { chat, room, server } = context; + if (chat.job || chat.jobToolNames) return translateJob(context, part); let ids = chat.messageIds ??= new Map(); switch (part.type) { case "finish": @@ -1324,6 +1474,10 @@ export async function resetAgent( chat.lifecycle++; chat.activeRequest = undefined; if (reason && chat.turnController) chat.interruption = reason; + if (chat.job) { + chat.job = undefined; + chat.jobOutput = undefined; + } chat.turnController?.abort(); chat.referenceCache.clear(); chat.bootstrapEntries = undefined; @@ -1333,8 +1487,17 @@ export async function resetAgent( /** Let go of the session. The conversation is resumable by id. */ export async function close(chat: Chat): Promise { chat.closed = true; + chat.job = undefined; + chat.jobOutput = undefined; chat.lifecycle++; chat.activeRequest = undefined; + for (let waiting of chat.waiting) { + if (waiting.job) finishJob(waiting.job, { status: "failed", reason: "The document closed." }); + } + if (chat.handoffJob) { + finishJob(chat.handoffJob, { status: "failed", reason: "The document closed." }); + chat.handoffJob = undefined; + } chat.waiting = []; chat.turnController?.abort(); chat.referenceCache.clear(); diff --git a/apps/server/src/config-conversation-bounds.test.ts b/apps/server/src/config-conversation-bounds.test.ts new file mode 100644 index 00000000..bf92cf47 --- /dev/null +++ b/apps/server/src/config-conversation-bounds.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { describe as description } from "./config"; +import { configured } from "./config-conversation.test-fixtures"; + +test("only the exact opt-in enables analysis and a generic transport key is insufficient", () => { + for (let flag of [undefined, "", "off", "ON", "true", "1"]) { + expect(configured({ CONVERSATION_PLAN: flag })).toMatchObject({ + conversationPlan: false, + conversationPlanModel: "jev-latest", + conversationPlanTimeoutMs: 30_000, + }); + } + expect(() => configured({ CONVERSATION_PLAN: "on", TYPESAFE_API_KEY: "synthetic" })) + .toThrow("JEV_API_KEY is required when CONVERSATION_PLAN=on"); + expect(() => configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "" })).toThrow("JEV_API_KEY"); +}); + +test("timeout bounds are validated even when conversation analysis is off", () => { + for (let timeout of ["100", "60000", "000100"]) { + expect(configured({ JEV_TIMEOUT_MS: timeout }).conversationPlanTimeoutMs).toBe(Number(timeout)); + } + for (let timeout of ["99", "60001", "-100", "+100", " 100", "100 ", "1e3", "9007199254740992"]) { + expect(() => configured({ JEV_TIMEOUT_MS: timeout })).toThrow("JEV_TIMEOUT_MS"); + } +}); + +test("model alias validation is opt-in and does not expose the transport credential", () => { + let config = configured({ + CONVERSATION_PLAN: "on", + JEV_API_KEY: "synthetic-test-key", + JEV_MODEL: "jev.experimental-v1_2", + JEV_TIMEOUT_MS: "100", + }); + expect(config.conversationPlanModel).toBe("jev.experimental-v1_2"); + expect(description(config)).toContain("conversation plan: jev.experimental-v1_2"); + expect(description(config)).not.toContain("synthetic-test-key"); + expect(configured({ JEV_MODEL: "invalid alias" }).conversationPlanModel).toBe("invalid alias"); + for (let alias of ["with spaces", "with/slash", "x".repeat(101)]) { + expect(() => + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "synthetic", JEV_MODEL: alias }) + ) + .toThrow("JEV_MODEL"); + } +}); diff --git a/apps/server/src/config-conversation.test-fixtures.ts b/apps/server/src/config-conversation.test-fixtures.ts new file mode 100644 index 00000000..c4ceabe2 --- /dev/null +++ b/apps/server/src/config-conversation.test-fixtures.ts @@ -0,0 +1,46 @@ +import { load } from "./config"; + +const REQUIRED = { + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", + APP_ORIGIN: "https://chopin.example", + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client-id", + GITHUB_APP_CLIENT_SECRET: "client-secret", + SESSION_ENCRYPTION_KEY: "11".repeat(32), +}; + +export function configured(overrides: Record = {}) { + let env: Record = { + ...REQUIRED, + HARNESS: undefined, + HARNESS_AUTH: undefined, + AUTH_MODE: undefined, + SERVER_HOST: undefined, + PORT: undefined, + CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, + AGENT: undefined, + MODEL: undefined, + BACKGROUND_JOBS: undefined, + WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + JEV_API_KEY: undefined, + TYPESAFE_API_KEY: undefined, + GITHUB_ALLOWED_USERS: undefined, + GITHUB_ALLOWED_ORGANIZATIONS: undefined, + ...overrides, + }; + let previous = { ...process.env }; + for (let [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return load(); + } finally { + for (let key of Object.keys(env)) delete process.env[key]; + Object.assign(process.env, previous); + } +} diff --git a/apps/server/src/config-conversation.test.ts b/apps/server/src/config-conversation.test.ts new file mode 100644 index 00000000..d19cef51 --- /dev/null +++ b/apps/server/src/config-conversation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { configured } from "./config-conversation.test-fixtures"; + +describe("conversation configuration", () => { + it("enables conversation analysis only by explicit opt-in, independently of Planner", () => { + expect(configured({ JEV_API_KEY: "present" }).conversationPlan).toBe(false); + expect(configured({ CONVERSATION_PLAN: "on", AGENT: "off", JEV_API_KEY: "present" })) + .toMatchObject({ agent: false, conversationPlan: true, conversationPlanModel: "jev-latest" }); + expect(() => configured({ CONVERSATION_PLAN: "on" })).toThrow("JEV_API_KEY"); + expect(() => + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present", JEV_MODEL: "bad name" }) + ) + .toThrow("JEV_MODEL"); + expect( + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present" }) + .conversationPlanTimeoutMs, + ).toBe(30_000); + expect( + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present", JEV_TIMEOUT_MS: "45000" }) + .conversationPlanTimeoutMs, + ).toBe(45_000); + for (let timeout of ["0", "99", "60001", "30s", "", "1.5"]) { + expect(() => + configured({ + CONVERSATION_PLAN: "on", + JEV_API_KEY: "present", + JEV_TIMEOUT_MS: timeout, + }) + ).toThrow("JEV_TIMEOUT_MS"); + } + }); +}); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 396c1634..ade6b346 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -18,6 +18,11 @@ function configured(overrides: Record = {}) { AGENT: undefined, BACKGROUND_JOBS: undefined, WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_API_KEY: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + TYPESAFE_API_KEY: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, AUTH_MODE: undefined, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d4b3da51..b082dd4c 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,10 @@ export type Config = { agent: boolean; backgroundJobs: boolean; webResearch: boolean; + /** Explicitly enables the conversation-derived cards prototype. */ + conversationPlan?: boolean; + conversationPlanModel?: string; + conversationPlanTimeoutMs?: number; /** * Origin of a running Vite, when developing. * @@ -97,6 +101,23 @@ function storage(): StorageConfig { export function load(): Config { let agent = process.env.AGENT !== "off"; let backgroundJobs = process.env.BACKGROUND_JOBS !== "off"; + let conversationPlan = process.env.CONVERSATION_PLAN === "on"; + let conversationPlanModel = process.env.JEV_MODEL || "jev-latest"; + let timeoutRaw = process.env.JEV_TIMEOUT_MS; + let conversationPlanTimeoutMs = timeoutRaw === undefined ? 30_000 : Number(timeoutRaw); + if ( + (timeoutRaw !== undefined && !/^\d+$/.test(timeoutRaw)) + || !Number.isSafeInteger(conversationPlanTimeoutMs) + || conversationPlanTimeoutMs < 100 || conversationPlanTimeoutMs > 60_000 + ) { + throw new Error("JEV_TIMEOUT_MS must be an integer between 100 and 60000 milliseconds"); + } + if (conversationPlan && !process.env.JEV_API_KEY) { + throw new Error("JEV_API_KEY is required when CONVERSATION_PLAN=on"); + } + if (conversationPlan && !/^[A-Za-z0-9._-]{1,100}$/.test(conversationPlanModel)) { + throw new Error("JEV_MODEL must be a valid model alias"); + } let selection = harnessSelection(); let serverPort = port(); return { @@ -106,6 +127,9 @@ export function load(): Config { agent, backgroundJobs, webResearch: agent && backgroundJobs && process.env.WEB_RESEARCH !== "off", + conversationPlan, + conversationPlanModel, + conversationPlanTimeoutMs, devClient: process.env.DEV_CLIENT || undefined, storage: storage(), auth: loadAuth(serverPort, selection.host), @@ -135,6 +159,9 @@ export function describe(config: Config): string { `harness: ${config.harness}`, config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", + config.conversationPlan + ? `conversation plan: ${config.conversationPlanModel}` + : "conversation plan: off", admission, `storage: ${config.storage.driver}`, ]; 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-storage.test-fixtures.ts b/apps/server/src/conversation-plan/accepted-research-storage.test-fixtures.ts new file mode 100644 index 00000000..3e106ae3 --- /dev/null +++ b/apps/server/src/conversation-plan/accepted-research-storage.test-fixtures.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; +import { broadcast } from "../wire"; +import { JobRegistry } from "../jobs/registry"; +import { JobService } from "../jobs/service"; +import { researchAnswerDefinition, researchEvidenceDefinition } from "../jobs/research-workspace"; +import { ResearchWorkspaceService } from "../research/service"; +import { REPORT } from "../research/test-support"; +import * as Plan from "../plan/service"; +import type { headingMemory } from "../chat/job-heading-memory.test-fixtures"; + +async function evidence() { + return { findings: [], sources: [] }; +} +async function privateEvidence() { + return { findings: [] }; +} +async function report() { + return REPORT; +} +async function answer() { + return { text: "Offline answer", sourceUrls: [] }; +} + +export function researchStorage(h: Awaited>) { + let jobs = new JobService({ + storage: h.opened.storage, + registry: new JobRegistry([ + researchEvidenceDefinition({ config: { agent: true, model: "offline" }, engine: evidence }), + researchAnswerDefinition({ + config: { agent: true, model: "offline" }, + engines: { private: privateEvidence, synthesize: report, answer }, + }), + ]), + lease: () => h.opened.lease, + }); + let research = new ResearchWorkspaceService({ + storage: h.opened.storage, + jobs, + lease: () => h.opened.lease, + current: async () => ({ + channelId: h.plan.id, + revision: h.plan.revision, + source: Plan.source(h.plan), + sourceHash: `sha256:${createHash("sha256").update(Plan.source(h.plan)).digest("hex")}`, + }), + publish: (channelId, workspaceId, revision) => + broadcast(h.opened.server, channelId, { + kind: "research:changed", + ts: 0, + workspaceId, + revision, + }), + }); + return { research, jobs }; +} 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/card-effect-prompts.ts b/apps/server/src/conversation-plan/card-effect-prompts.ts new file mode 100644 index 00000000..81ac753b --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-prompts.ts @@ -0,0 +1,107 @@ +import * as Chat from "../chat/service"; + +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands } from "./service"; + +import { activeSettleDeferral, effectivePreference } from "./preference"; + +import { promptText, sameSource, shouldPrompt } from "./card-prompts"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardPrompts( + plan: Plan, + server: Server, + roomId: string, + announcer: Chat.Announcer, +): Pick { + return { + prompt: async (id, generation, source) => { + await Service.exclusive(plan, async () => { + let record = plan.records.get(id); + if (!record) throw new Error("prompt card is missing"); + let thread = plan.conversationPlan.threads.find(item => item.id === record.threadId); + if (thread && activeSettleDeferral(thread, plan.conversationPlan.events)) return; + if (thread?.status === "reopened" && !thread.pendingSettle) return; + if (source && thread?.pendingSettle) { + let events = plan.conversationPlan.events; + let deferredAt = events.findLastIndex(item => + item.type === "settle.deferred" && item.threadId === thread.id + ); + if (deferredAt >= 0) { + let triggerAt = events.findLastIndex(item => + item.threadId === thread.id && "source" in item + && item.source?.messageId === source.messageId + && (item.type === "settle.suggested" || item.type === "settle.agreed" + || item.type === "settle.resumed" || item.type === "stance.changed") + ); + if (triggerAt <= deferredAt) return; + } + let current = effectivePreference(thread, plan.conversationPlan.events); + if (source.sourceMessageIds !== undefined) { + if (!sameSource(current, source.optionId, source.sourceMessageIds)) { + return; + } + } else if ( + current?.optionId !== source.optionId + || !current?.messageIds.includes(source.messageId) + ) return; + } + let title = record.definition.questions[0]?.question; + if (!title) throw new Error("prompt card title is missing"); + let identity = source?.sourceMessageIds === undefined ? undefined : { + optionId: source.optionId, + messageIds: source.sourceMessageIds, + }; + if (!shouldPrompt(plan.chat.entries, id, record, generation, identity)) return; + await Chat.noticeExclusive(announcer, { + text: promptText(title), + decision: { + questionnaireId: id, + kind: "prompt", + generation, + ...(identity + ? { + sourceMessageIds: [...identity.messageIds], + ...(identity.optionId ? { suggestedOptionId: identity.optionId } : {}), + } + : {}), + }, + }); + }); + }, + deferPrompt: async effect => { + await Service.exclusive(plan, async () => { + let thread = plan.conversationPlan.threads.find(item => item.id === effect.threadId); + let deferred = thread && activeSettleDeferral(thread, plan.conversationPlan.events); + if ( + !thread || !deferred || deferred.id !== effect.deferredEventId + || deferred.proposalId !== effect.proposalId || !thread.questionnaireId + ) return; + let id = thread.questionnaireId; + let record = plan.records.get(id); + if (!record || !Questions.isOpenStatus(record.status)) return; + let title = record.definition.questions[0]?.question; + if (!title) throw new Error("deferred card title is missing"); + let cleared = await Questions.suggest(plan, server, roomId, id, undefined, true); + if (!cleared) throw new Error("deferred card is temporarily unavailable"); + for (let entry of plan.chat.entries) { + if ( + entry.decision?.kind !== "prompt" || entry.decision.questionnaireId !== id + || entry.decision.generation !== record.history.length + ) continue; + await Chat.refreshNoticeExclusive(announcer, entry.id, { + text: `Decision paused pending verification: ${title}`, + decision: { questionnaireId: id, kind: "activity" }, + }); + } + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-scoped.ts b/apps/server/src/conversation-plan/card-effect-scoped.ts new file mode 100644 index 00000000..ef74daf7 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-scoped.ts @@ -0,0 +1,116 @@ +import * as Chat from "../chat/service"; +import { isDeepStrictEqual } from "node:util"; + +import * as Service from "../plan/service"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; + +import type { EffectCommands } from "./service"; + +import { activeScopedSupport, currentScopedProposal, targetsScopedProposal } from "./events"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createScopedNotice( + plan: Plan, + announcer: Chat.Announcer, +): Pick { + return { + scopedChoice: async effect => { + await Service.exclusive(plan, async () => { + let state = plan.conversationPlan; + let thread = state.threads.find(item => item.id === effect.threadId); + let record = plan.records.get(effect.cardId); + if (!record) throw new Error("scoped choice card is missing"); + let trigger = state.events.find(item => item.id === effect.triggerEventId); + let proposal = trigger?.type === "stance.changed" + ? state.events.find((item): item is Extract => item.type === "scoped-choice.proposed" && item.id === effect.proposalId) + : thread && currentScopedProposal(thread, state.events); + let sources = proposal + ? [ + proposal.source, + ...(trigger?.type === "scoped-choice.agreed" || trigger?.type === "stance.changed" + ? [trigger.source] + : []), + ] + : []; + if ( + !thread || !proposal || proposal.id !== effect.proposalId + || thread.questionnaireId !== effect.cardId + || ["decided", "discarded"].includes(thread.status) + || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + || record.history.length !== effect.generation + || record.definition.questions[0]?.options.filter(item => + item.id === effect.optionId && item.label === effect.label + ).length !== 1 + || proposal.cardId !== effect.cardId || proposal.optionId !== effect.optionId + || proposal.label !== effect.label || proposal.scope !== effect.scope + || thread.pendingScopedChoice + && thread.pendingScopedChoice.proposalId !== proposal.id + || state.events.some(item => + item.type === "scoped-choice.saved" && item.proposalId === proposal.id + ) + || trigger?.type !== "scoped-choice.proposed" + && trigger?.type !== "scoped-choice.agreed" + && trigger?.type !== "stance.changed" + || trigger.type === "scoped-choice.proposed" && trigger.id !== proposal.id + || trigger.type === "scoped-choice.agreed" && trigger.proposalId !== proposal.id + || trigger.type === "stance.changed" && ( + trigger.threadId !== thread.id + || !targetsScopedProposal(trigger, proposal.id, proposal.optionId) + || trigger.source.author.kind !== "member" + ) + || trigger.type !== "stance.changed" && !thread.pendingScopedChoice + || !isDeepStrictEqual(sources, effect.sources) + ) return; + let active = activeScopedSupport(state.events, proposal); + let decision = { + questionnaireId: effect.cardId, + kind: "scoped-choice" as const, + threadId: effect.threadId, + proposalId: effect.proposalId, + cardId: effect.cardId, + optionId: effect.optionId, + label: effect.label, + scope: effect.scope, + generation: effect.generation, + triggerEventId: active.at(-1)?.id ?? proposal.id, + sources: active.map(item => structuredClone(item.source)), + }; + let text = `Ready to save ${effect.label} for this spike.`; + let existing = plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + && entry.decision.threadId === effect.threadId + && entry.decision.questionnaireId === effect.cardId + && entry.decision.cardId === effect.cardId + && entry.decision.optionId === effect.optionId + && entry.decision.scope === effect.scope + && entry.decision.generation === effect.generation + ); + if (!thread.pendingScopedChoice || active.length === 0) { + if (existing.length > 1) throw new Error("scoped choice notice identity conflicts"); + if (existing[0]) { + await Chat.refreshNoticeExclusive(announcer, existing[0].id, { + text: `Scoped Save for ${effect.label} is no longer available.`, + decision: { questionnaireId: effect.cardId, kind: "activity" }, + }); + } + return; + } + if (existing.length > 0) { + if ( + existing.length !== 1 || existing[0].author.kind !== "system" + || existing[0].text !== text + ) throw new Error("scoped choice notice identity conflicts"); + if (isDeepStrictEqual(existing[0].decision, decision)) return; + await Chat.refreshNoticeExclusive(announcer, existing[0].id, { text, decision }); + return; + } + await Chat.noticeExclusive(announcer, { text, decision }); + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-suggestion.ts b/apps/server/src/conversation-plan/card-effect-suggestion.ts new file mode 100644 index 00000000..9e7b6285 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-suggestion.ts @@ -0,0 +1,50 @@ +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands } from "./service"; + +import { activeSettleDeferral, effectivePreference } from "./preference"; + +import { sameSource } from "./card-prompts"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardSuggestion( + plan: Plan, + server: Server, + roomId: string, +): Pick { + return { + suggest: async (id, input) => { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + if (!record) throw new Error("card is missing"); + let thread = plan.conversationPlan.threads.find(item => item.id === record.threadId); + if (thread && activeSettleDeferral(thread, plan.conversationPlan.events)) return; + if (thread?.status === "reopened" && !thread.pendingSettle) return; + if (thread?.pendingSettle) { + let current = effectivePreference(thread, plan.conversationPlan.events); + if (!sameSource(current, input.optionId, input.messageIds)) return; + } + let applied = await Questions.suggest( + plan, + server, + roomId, + id, + input.optionId ? { optionId: input.optionId, messageIds: input.messageIds } : undefined, + true, + ); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (!applied && Questions.isOpenStatus(plan.records.get(id)?.status ?? "cancelled")) { + let live = Store.get(plan.questions, id); + if (!live || live.claim) throw new Error("card is temporarily unavailable"); + } + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-targets.ts b/apps/server/src/conversation-plan/card-effect-targets.ts new file mode 100644 index 00000000..182c8884 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-targets.ts @@ -0,0 +1,91 @@ +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands, Processor } from "./service"; +import { proseIntent } from "./prose-job"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardTargets( + plan: Plan, + server: Server, + roomId: string, + processor: Processor, +): Pick { + return { + proseReady: (threadId, id, trigger) => { + let thread = plan.conversationPlan.threads.find(item => item.id === threadId); + let record = plan.records.get(id); + return !!record && thread?.questionnaireId === id && record.threadId === threadId + && proseIntent(record)?.trigger === trigger; + }, + target: threadId => { + let id = plan.conversationPlan.threads.find(item => item.id === threadId)?.questionnaireId; + if (!id) return { kind: "unlinked" }; + let record = plan.records.get(id); + if (!record) return { kind: "unlinked" }; + return record.status === "open" || record.status === "reopened" + ? { kind: "open", id } + : { kind: "closed" }; + }, + insertCard: async input => { + let linked = plan.conversationPlan.threads.find(item => item.id === input.threadId) + ?.questionnaireId; + if (linked && !plan.records.has(linked)) throw new Error("linked card is missing"); + return await Questions.insertConversationCard(plan, server, roomId, input); + }, + link: async (threadId, questionnaireId) => { + await processor.record(state => { + let thread = state.threads.find(item => item.id === threadId); + if (!thread) throw new Error("card link thread is missing"); + if (thread.questionnaireId === questionnaireId) return; + if (thread.questionnaireId) throw new Error("thread already has a different card"); + return { + id: `card:${questionnaireId}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: Math.floor(Date.now() / 1_000), + questionnaireId, + }; + }); + }, + addOption: async (id, input) => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + if (!record) throw new Error("card is missing"); + let existing = record.definition.questions[0]?.options.find(option => + option.id === input.optionId + ); + if (existing) { + // A later title refinement may have changed this option's label. + if (record.optionOrigins[input.optionId]?.origin === "chat") return; + console.error("[conversation-plan] option ID belongs to another origin:", input.optionId); + return; + } + let result = await Questions.addServerOption(plan, server, roomId, id, { + ...input, + origin: "chat", + }); + if (!result.ok) { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + // A card still closed after Save or Discard supersedes the undelivered addition. + if (!Questions.isOpenStatus(plan.records.get(id)?.status ?? "cancelled")) return; + if ( + plan.conversationPlan.events.some(event => + event.id === input.trigger && event.origin === "human" + && event.type === "option.added" + ) + ) throw new Error(`Could not add the accepted excerpt option: ${result.reason}`); + if (result.reason === "closed") throw new Error("card is temporarily unavailable"); + console.error("[conversation-plan] could not represent a quoted option:", result); + } + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-mirror-runner.ts b/apps/server/src/conversation-plan/card-mirror-runner.ts new file mode 100644 index 00000000..0473442d --- /dev/null +++ b/apps/server/src/conversation-plan/card-mirror-runner.ts @@ -0,0 +1,43 @@ +import type { Plan } from "../plan/service"; + +import type { Processor } from "./service"; + +import { mirroredEvent } from "./card-mirror"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +let drains = new WeakMap>(); + +export async function mirrorCard(plan: Plan, processor: Processor): Promise { + let existing = drains.get(plan); + if (existing) return existing; + let succeeded = false; + let operation = (async () => { + while (plan.pendingCardActions.length > 0) { + let action = plan.pendingCardActions[0]!; + await processor.record(state => { + let thread = state.threads.find(item => item.id === action.threadId); + if (!thread) throw new Error("card mirror thread is missing"); + return mirroredEvent(thread, action); + }, action.id); + } + succeeded = true; + })(); + drains.set(plan, operation); + try { + await operation; + } finally { + if (drains.get(plan) === operation) drains.delete(plan); + if (succeeded && plan.pendingCardActions.length > 0) { + queueMicrotask(() => { + void mirrorCard(plan, processor).catch(error => + console.error("[conversation-plan] could not mirror a card action:", error) + ); + }); + } + } +} + +export async function wakeCardMirror(plan: Plan, processor: Processor): Promise { + processor.wake(); + await mirrorCard(plan, processor); +} diff --git a/apps/server/src/conversation-plan/card-mirror.ts b/apps/server/src/conversation-plan/card-mirror.ts new file mode 100644 index 00000000..f7279d5e --- /dev/null +++ b/apps/server/src/conversation-plan/card-mirror.ts @@ -0,0 +1,62 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { PendingCardAction } from "../questions/card-actions"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, cards.ts pure event derivation. +export function mirroredEvent( + thread: ConversationPlan.Thread, + action: PendingCardAction, +): ConversationPlan.Event | undefined { + let base = { + id: action.id, + threadId: action.threadId, + observedThreadVersion: thread.version, + at: action.at, + }; + switch (action.kind) { + case "decided": { + let chosen = action.optionIds.find(id => + thread.contributions.some(item => item.kind === "option" && item.id === id) + ); + return { + ...base, + type: "decision.recorded", + origin: "human", + actor: { kind: "member", handle: action.actor }, + text: action.text, + ...(chosen ? { optionId: chosen } : {}), + explicit: true, + }; + } + case "reopened": + return { + ...base, + type: "decision.reopened", + origin: "human", + actor: { kind: "member", handle: action.actor }, + explicit: true, + }; + case "discarded": + return { + ...base, + type: "thread.discarded", + origin: "human", + actor: { kind: "member", handle: action.actor }, + }; + case "option-added": + if (thread.contributions.some(item => item.id === action.optionId)) return; + return { + ...base, + type: "option.added", + origin: action.origin, + actor: action.origin === "planner" + ? { kind: "agent" } + : { kind: "member", handle: action.actor }, + contribution: { + id: action.optionId, + text: action.label, + authoring: action.origin === "planner" ? "scribe" : "human-edited", + targetId: thread.id, + }, + }; + } +} diff --git a/apps/server/src/conversation-plan/card-prompts.ts b/apps/server/src/conversation-plan/card-prompts.ts new file mode 100644 index 00000000..5bc39d8b --- /dev/null +++ b/apps/server/src/conversation-plan/card-prompts.ts @@ -0,0 +1,42 @@ +import type { Chat as WireChat } from "@chopin/protocol"; + +import { effectivePreference } from "./preference"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function promptText(title: string): string { + return `Ready to decide: ${title}`; +} + +export function sameSource( + current: ReturnType, + optionId: string | undefined, + messageIds: readonly string[], +): boolean { + let currentIds = current?.messageIds ?? []; + return current?.optionId === optionId && currentIds.length === messageIds.length + && currentIds.every((id, index) => id === messageIds[index]); +} + +export function shouldPrompt( + entries: readonly WireChat.Entry[], + questionnaireId: string, + record: { status: string; history: readonly unknown[] }, + generation: number, + source?: { optionId?: string; messageIds: readonly string[] }, +): boolean { + if (record.status !== "open" && record.status !== "reopened") return false; + if (record.history.length !== generation) return false; + for (let index = entries.length - 1; index >= 0; index--) { + let decision = entries[index]?.decision; + if ( + decision?.kind !== "prompt" || decision.questionnaireId !== questionnaireId + || decision.generation !== generation + ) continue; + if (!source) return false; + return decision.sourceMessageIds === undefined + || decision.suggestedOptionId !== source.optionId + || decision.sourceMessageIds.length !== source.messageIds.length + || decision.sourceMessageIds.some((id, index) => id !== source.messageIds[index]); + } + return true; +} diff --git a/apps/server/src/conversation-plan/cards-1.test.ts b/apps/server/src/conversation-plan/cards-1.test.ts new file mode 100644 index 00000000..0b32ef63 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-1.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import { mirroredEvent, promptText, shouldPrompt } from "./cards"; + +import type { PendingCardAction } from "../questions/card-actions"; + +import { base, CARD, OPTION, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a decided card mirrors its captured actor, choice, text and stable generation", () => { + let action: PendingCardAction = { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }; + expect(mirroredEvent(thread(), action)).toMatchObject({ + id: action.id, + type: "decision.recorded", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 9, + optionId: OPTION, + text: "GitHub Apps", + explicit: true, + }); +}); + +test("reopen, discard and human or Planner options retain their captured identity", () => { + let reopen: PendingCardAction = { + ...base(), + id: `card:${CARD}:reopened:1`, + kind: "reopened", + generation: 1, + }; + expect(mirroredEvent(thread("decided"), reopen)).toMatchObject({ + id: reopen.id, + type: "decision.reopened", + actor: { kind: "member", handle: "ana" }, + }); + let discard: PendingCardAction = { + ...base(), + id: `card:${CARD}:discarded`, + kind: "discarded", + }; + expect(mirroredEvent(thread(), discard)).toMatchObject({ + id: discard.id, + type: "thread.discarded", + }); + let option: PendingCardAction = { + ...base(), + id: `card:${CARD}:option:01K0N4W3B7P27CBAEC7A8C8WEC`, + kind: "option-added", + origin: "human", + optionId: "01K0N4W3B7P27CBAEC7A8C8WEC", + label: "Own service", + }; + expect(mirroredEvent(thread(), option)).toMatchObject({ + id: option.id, + type: "option.added", + origin: "human", + contribution: { id: option.optionId, text: "Own service", authoring: "human-edited" }, + }); + let planner = { ...option, origin: "planner" as const }; + expect(mirroredEvent(thread(), planner)).toMatchObject({ + type: "option.added", + origin: "planner", + actor: { kind: "agent" }, + contribution: { authoring: "scribe" }, + }); + expect(mirroredEvent(thread(), { ...option, optionId: OPTION })).toBeUndefined(); +}); + +test("prompt generations distinguish same-second Save and Reopen", () => { + let entries = [{ + id: "p0", + author: { kind: "system" as const }, + text: "Ready to decide: Which auth system?", + ts: 9, + decision: { questionnaireId: CARD, kind: "prompt" as const, generation: 0 }, + }]; + let open = { status: "open", history: [] }; + let reopened = { status: "reopened", history: [{ at: 9 }] }; + let answered = { status: "answered", history: [] }; + let discarded = { status: "discarded", history: [] }; + let later = { ...entries[0], id: "p1", decision: { ...entries[0].decision, generation: 1 } }; + let other = { + ...entries[0], + id: "other", + decision: { ...entries[0].decision, questionnaireId: ulid() }, + }; + expect(promptText("Which auth system?")).toBe("Ready to decide: Which auth system?"); + expect(shouldPrompt([], CARD, open, 0)).toBe(true); + expect(shouldPrompt(entries, CARD, open, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, reopened, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, reopened, 1)).toBe(true); + expect(shouldPrompt([...entries, later], CARD, reopened, 1)).toBe(false); + expect(shouldPrompt([other], CARD, open, 0)).toBe(true); + expect(shouldPrompt([], CARD, answered, 0)).toBe(false); + expect(shouldPrompt([], CARD, discarded, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, open, 0, { optionId: OPTION, messageIds: ["m5"] })) + .toBe(true); + let sourced = [{ + ...entries[0], + decision: { ...entries[0].decision, suggestedOptionId: OPTION, sourceMessageIds: ["m5"] }, + }]; + expect(shouldPrompt(sourced, CARD, open, 0, { optionId: OPTION, messageIds: ["m5"] })) + .toBe(false); + expect(shouldPrompt(sourced, CARD, open, 0, { optionId: OPTION, messageIds: ["m6"] })) + .toBe(true); +}); + +test("source-aware prompt retries compare the latest notice, including recurring clears", () => { + let open = { status: "open", history: [] }; + let notice = (id: string, sourceMessageIds: string[], suggestedOptionId?: string) => ({ + id, + author: { kind: "system" as const }, + text: "Ready to decide: Which auth system?", + ts: 9, + decision: { + questionnaireId: CARD, + kind: "prompt" as const, + generation: 0, + sourceMessageIds, + ...(suggestedOptionId ? { suggestedOptionId } : {}), + }, + }); + let bram = { optionId: OPTION, messageIds: ["m5"] }; + let clear = { messageIds: [] }; + let both = notice("both", ["m5", "m6"], OPTION); + let first = notice("first", ["m5"], OPTION); + let firstClear = notice("first-clear", []); + expect(shouldPrompt([first, both], CARD, open, 0, bram)).toBe(true); + expect(shouldPrompt([first, both, notice("returned", ["m5"], OPTION)], CARD, open, 0, bram)) + .toBe(false); + expect(shouldPrompt([first, firstClear, notice("new", ["m9"], OPTION)], CARD, open, 0, clear)) + .toBe(true); + expect(shouldPrompt([first, firstClear], CARD, open, 0, clear)).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/cards-2.test.ts b/apps/server/src/conversation-plan/cards-2.test.ts new file mode 100644 index 00000000..997a9a15 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-2.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; + +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { cardSource, OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("source withdrawal and recurring clear supersede durable Chat notices", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let proposal: ConversationPlan.Event = { + id: "proposal", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: cardSource("m5", "Bram", "resolution"), + optionId: OPTION, + }; + let agreement: ConversationPlan.Event = { + ...proposal, + id: "agreement", + type: "settle.agreed", + source: cardSource("m6", "Ada", "support"), + }; + let adaWithdrawal: ConversationPlan.Event = { + ...proposal, + id: "ada-withdrawal", + type: "stance.changed", + scopedProposalId: null, + source: cardSource("m7", "Ada", "objection"), + position: "oppose", + }; + let bramWithdrawal: ConversationPlan.Event = { + ...adaWithdrawal, + id: "bram-withdrawal", + source: cardSource("m8", "Bram", "objection"), + }; + let newProposal: ConversationPlan.Event = { + ...proposal, + id: "new-proposal", + source: cardSource("m9", "Bram", "resolution"), + }; + let secondWithdrawal: ConversationPlan.Event = { + ...bramWithdrawal, + id: "second-withdrawal", + source: cardSource("m10", "Bram", "objection"), + }; + context.plan.conversationPlan.threads = [{ + ...thread(), + questionnaireId: id, + pendingSettle: { optionId: OPTION, proposer: "Bram", messageId: "m5" }, + }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let records = new Map([[id, { history: [] }]]); + for ( + let [event, expectedSources] of [ + [proposal, ["m5"]], + [agreement, ["m5", "m6"]], + [adaWithdrawal, ["m5"]], + [bramWithdrawal, []], + [newProposal, ["m9"]], + [secondWithdrawal, []], + ] as const + ) { + context.plan.conversationPlan.events.push(event); + if (event === newProposal) { + context.plan.conversationPlan.threads[0]!.pendingSettle = { + optionId: OPTION, + proposer: "Bram", + messageId: "m9", + }; + } + expect( + await runEffects( + deps, + effectsFor([event], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds ?? []) + .toEqual([...expectedSources]); + } + expect(Store.get(context.plan.questions, id)?.suggested).toBeUndefined(); + expect( + context.plan.chat.entries.map(entry => + entry.decision?.kind === "prompt" ? entry.decision.sourceMessageIds : undefined + ), + ).toEqual([ + ["m5"], + ["m5", "m6"], + ["m5"], + [], + ["m9"], + [], + ]); + expect( + await runEffects( + { ...deps, applied: () => false }, + effectsFor([secondWithdrawal], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(context.plan.chat.entries).toHaveLength(6); + expect(context.plan.records.get(id)?.history).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/cards-3.test.ts b/apps/server/src/conversation-plan/cards-3.test.ts new file mode 100644 index 00000000..6616d40e --- /dev/null +++ b/apps/server/src/conversation-plan/cards-3.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from "bun:test"; + +import { cardEffects, mirrorCard, wakeCardMirror } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { PendingCardAction } from "../questions/card-actions"; +import type { Processor } from "./service"; +import { base, CARD, cardSource, OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a withdrawn proposer refreshes the stored advisory and durable Chat prompt to the surviving agreement", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let proposed: ConversationPlan.Event = { + id: "proposed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: cardSource("m5", "Bram", "resolution"), + optionId: OPTION, + }; + let agreed: ConversationPlan.Event = { + ...proposed, + id: "agreed", + type: "settle.agreed", + source: cardSource("m6", "Ada", "support"), + }; + let withdrawn: ConversationPlan.Event = { + ...proposed, + id: "withdrawn", + type: "stance.changed", + scopedProposalId: null, + source: cardSource("m7", "Bram", "objection"), + position: "oppose", + }; + context.plan.conversationPlan.threads = [{ + ...thread(), + questionnaireId: id, + pendingSettle: { optionId: OPTION, proposer: "Bram", messageId: "m5" }, + }]; + context.plan.conversationPlan.events = [proposed]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let records = new Map([[id, { history: [] }]]); + let first = effectsFor([proposed], context.plan.conversationPlan, undefined, records); + expect(await runEffects(deps, first)).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m5"]); + context.plan.conversationPlan.events = [proposed, agreed, withdrawn]; + let latest = effectsFor([agreed, withdrawn], context.plan.conversationPlan, undefined, records); + expect(await runEffects(deps, latest)).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m6"]); + expect( + context.plan.chat.entries.map(entry => + entry.decision?.kind === "prompt" ? entry.decision.sourceMessageIds : undefined + ), + ).toEqual([ + ["m5"], + ["m6"], + ]); + expect(context.plan.records.get(id)?.history).toEqual([]); + let adaWithdrawn: ConversationPlan.Event = { + ...withdrawn, + id: "ada-withdrawn", + source: cardSource("m8", "Ada", "objection"), + }; + context.plan.conversationPlan.events.push(adaWithdrawn); + expect( + await runEffects( + deps, + effectsFor([adaWithdrawn], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested).toBeUndefined(); + expect(context.plan.chat.entries.at(-1)?.decision).toMatchObject({ sourceMessageIds: [] }); + expect(context.plan.records.get(id)?.history).toEqual([]); + expect(await runEffects({ ...deps, applied: () => false }, first)).toBe(2); + expect(context.plan.chat.entries).toHaveLength(3); +}); + +test("recovery mirrors held Save, Reopen, Save before card.linked and retries a failed first write", async () => { + let actions: PendingCardAction[] = [ + { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }, + { + ...base(), + id: `card:${CARD}:reopened:1`, + kind: "reopened", + generation: 1, + actor: "ben", + at: 10, + }, + { + ...base(), + id: `card:${CARD}:decided:2`, + kind: "decided", + generation: 2, + actor: "cy", + at: 11, + optionIds: [OPTION], + text: "GitHub Apps", + }, + ]; + let plan = { + pendingCardActions: structuredClone(actions), + conversationPlan: { ...initialState(), threads: [thread()] }, + } as Plan; + let fail = true; + let processor = { + record: async ( + make: (state: ConversationPlan.State) => ConversationPlan.Event | undefined, + id: string, + ) => { + if (fail) { + fail = false; + throw new Error("storage failed"); + } + expect(plan.pendingCardActions[0]!.id).toBe(id); + let event = make(plan.conversationPlan); + if (event) plan.conversationPlan = applyEvent(plan.conversationPlan, event); + plan.pendingCardActions = plan.pendingCardActions.slice(1); + return !!event; + }, + } as unknown as Processor; + await expect(mirrorCard(plan, processor)).rejects.toThrow("storage failed"); + expect(plan.pendingCardActions).toEqual(actions); + await mirrorCard(plan, processor); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.conversationPlan.threads[0]!.questionnaireId).toBeUndefined(); + expect(plan.conversationPlan.threads[0]!.decisionHistory.map(item => item.actor)).toEqual([ + { kind: "member", handle: "ana" }, + { kind: "member", handle: "cy" }, + ]); + expect(plan.conversationPlan.events.map(item => item.id)).toEqual(actions.map(item => item.id)); +}); + +test("normal room wake retries one failed mirror without spinning", async () => { + let action: PendingCardAction = { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }; + let plan = { + pendingCardActions: [action], + conversationPlan: { ...initialState(), threads: [thread()] }, + } as Plan; + let attempts = 0; + let wakes = 0; + let processor = { + wake() { + wakes++; + }, + record: async (make: (state: ConversationPlan.State) => ConversationPlan.Event, id: string) => { + attempts++; + if (attempts === 1) throw new Error("storage unavailable"); + expect(plan.pendingCardActions[0]?.id).toBe(id); + plan.conversationPlan = applyEvent(plan.conversationPlan, make(plan.conversationPlan)); + plan.pendingCardActions = []; + return true; + }, + } as unknown as Processor; + await expect(mirrorCard(plan, processor)).rejects.toThrow("storage unavailable"); + expect(plan.pendingCardActions).toEqual([action]); + expect(attempts).toBe(1); + await wakeCardMirror(plan, processor); + expect(wakes).toBe(1); + expect(attempts).toBe(2); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.conversationPlan.threads[0]?.decisionHistory[0]?.text).toBe("GitHub Apps"); +}); diff --git a/apps/server/src/conversation-plan/cards-4.test.ts b/apps/server/src/conversation-plan/cards-4.test.ts new file mode 100644 index 00000000..05785d38 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-4.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; + +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; + +import type { Processor } from "./service"; +import { CARD, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a full card is terminal and does not block a later quoted option", async () => { + let context = await openPlan(); + plans.push(context.plan); + let full = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "full-thread", + header: "Full", + question: "Which?", + options: Array.from({ length: 10 }, (_, index) => ({ + id: ulid(), + label: `Choice ${index}`, + })), + }); + let open = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "open-thread", + header: "Open", + question: "Which?", + options: [], + }); + context.plan.conversationPlan.threads = [ + { ...thread(), id: "full-thread", questionnaireId: full, contributions: [] }, + { ...thread(), id: "open-thread", questionnaireId: open, contributions: [] }, + ]; + let accepted = new Set(); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let first = ulid(); + let second = ulid(); + let completed = await runEffects({ + ...commands, + applied: key => accepted.has(key), + markApplied: async key => { + accepted.add(key); + }, + }, [ + { + key: `option:${first}`, + kind: "add-option", + threadId: "full-thread", + optionId: first, + label: "Too many", + trigger: "m1", + }, + { + key: `option:${second}`, + kind: "add-option", + threadId: "open-thread", + optionId: second, + label: "GitHub Apps", + trigger: "m2", + }, + ]); + expect(completed).toBe(2); + expect(accepted).toEqual(new Set([`option:${first}`, `option:${second}`])); + expect(context.plan.records.get(full)!.definition.questions[0]!.options).toHaveLength(10); + expect(context.plan.records.get(open)!.definition.questions[0]!.options).toMatchObject([{ + id: second, + label: "GitHub Apps", + }]); + expect(context.plan.pendingCardActions).toEqual([]); + let record = context.plan.records.get(open)!; + context.plan.records.set(open, { + ...record, + definition: { + questions: [{ + ...record.definition.questions[0]!, + options: [{ ...record.definition.questions[0]!.options[0]!, label: "Refined" }], + }], + }, + }); + await commands.addOption(open, { optionId: second, label: "GitHub Apps" }); + expect(context.plan.records.get(open)!.definition.questions[0]!.options[0]!.label) + .toBe("Refined"); + context.plan.execution = { id: "run-1" } as never; + await expect(commands.addOption(open, { optionId: ulid(), label: "Deferred" })) + .rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + expect(Store.reserveEdit(context.plan.questions, open)).toBe(true); + await expect(commands.addOption(open, { optionId: ulid(), label: "Busy" })) + .rejects.toThrow("temporarily unavailable"); + Store.releaseEdit(context.plan.questions, open); +}); + +test("closed-card prose readiness requires the linked thread and saved generation", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Decision", + question: "Which?", + options: [], + }); + let record = context.plan.records.get(id)!; + context.plan.records.set(id, { + ...record, + status: "answered", + owner: "ana", + decidedAt: 1, + } as never); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + expect(commands.proseReady?.("thread-a", id, `decided:${id}:1`)).toBe(true); + expect(commands.proseReady?.("other-thread", id, `decided:${id}:1`)).toBe(false); + expect(commands.proseReady?.("thread-a", "other-card", `decided:${id}:1`)).toBe(false); + expect(commands.proseReady?.("thread-a", id, `decided:${id}:2`)).toBe(false); +}); + +test("a linked but missing card defers insertion instead of creating a second card", async () => { + let context = await openPlan(); + plans.push(context.plan); + context.plan.conversationPlan.threads = [{ + ...thread(), + id: "missing-thread", + questionnaireId: CARD, + }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + await expect(commands.insertCard({ + threadId: "missing-thread", + header: "Auth", + question: "Which?", + options: [], + })).rejects.toThrow("linked card is missing"); + expect(context.plan.records.size).toBe(0); +}); diff --git a/apps/server/src/conversation-plan/cards-5.test.ts b/apps/server/src/conversation-plan/cards-5.test.ts new file mode 100644 index 00000000..583e724b --- /dev/null +++ b/apps/server/src/conversation-plan/cards-5.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { type Effect, runEffects } from "./effects"; + +import * as Questions from "../questions/service"; + +import { openPlan } from "../testing/plan"; + +import type { Processor } from "./service"; +import { OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a delayed old agreement cannot become a fresh prompt after Save and Reopen", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let effect = (name: string, generation: number): Effect => ({ + key: `prompt:${name}`, + kind: "prompt", + threadId: "thread-a", + optionId: OPTION, + messageId: name, + generation, + }); + expect(await runEffects(deps, [effect("first", 0)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0]); + let ws = { + data: { handle: "ana", client: "client", room: "test" }, + send() {}, + publish() {}, + } as never; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(context.plan.records.get(id)).toMatchObject({ status: "reopened", history: [{}] }); + expect(await runEffects(deps, [effect("delayed-old", 0)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0]); + expect(await runEffects(deps, [effect("fresh", 1)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0, 1]); +}); + +test("failed prompt persistence and receipt retry produce one durable notice", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let original = context.storage.collaboration.commit; + let failCommit = true; + context.storage.collaboration.commit = async input => { + if (failCommit) { + failCommit = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + let receipts = new Set(); + let errors: unknown[] = []; + let failReceipt = true; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (failReceipt && context.plan.chat.entries.length) { + failReceipt = false; + throw new Error("receipt unavailable"); + } + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + let prompt: Effect = { + key: "prompt:agreement", + kind: "prompt", + threadId: "thread-a", + optionId: OPTION, + messageId: "agreement", + generation: 0, + }; + expect(await runEffects(deps, [prompt])).toBe(0); + expect(context.plan.chat.entries).toEqual([]); + expect(receipts.size).toBe(0); + expect(await runEffects(deps, [prompt])).toBe(0); + expect(context.plan.chat.entries).toHaveLength(1); + expect(receipts.size).toBe(0); + expect(await runEffects(deps, [prompt])).toBe(1); + expect(context.plan.chat.entries).toHaveLength(1); + expect(context.plan.chat.entries[0]?.decision).toMatchObject({ + questionnaireId: id, + kind: "prompt", + generation: 0, + }); + expect(receipts).toEqual(new Set(["prompt:agreement"])); + expect(errors.map(String)).toEqual(["Error: storage unavailable", "Error: receipt unavailable"]); +}); diff --git a/apps/server/src/conversation-plan/cards-6.test.ts b/apps/server/src/conversation-plan/cards-6.test.ts new file mode 100644 index 00000000..474601c0 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-6.test.ts @@ -0,0 +1,181 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { OPTION, plans } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a scoped choice notice keeps every agreement across retries and delayed effects", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let opened: ConversationPlan.Event = { + id: "scoped-open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which auth system?", + }; + let state = applyEvent(initialState(), opened); + state = applyEvent(state, { + id: "scoped-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + questionnaireId: id, + }); + let quote = "I'd pick GitHub Apps for the spike;"; + let proposal: ConversationPlan.Event = { + id: "scoped-proposal", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: { + messageId: "scoped-m6", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "support", + }, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + state = applyEvent(state, proposal); + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: "scoped-m6", + author: { kind: "member", handle: "mei" }, + text: quote, + ts: 2, + }); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let effects = effectsFor([proposal], state, undefined, context.plan.records); + let receipts = new Set(); + let failReceipt = true; + let errors: unknown[] = []; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (failReceipt) { + failReceipt = false; + throw new Error("receipt unavailable"); + } + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + expect(await runEffects(deps, effects)).toBe(0); + expect(context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice")) + .toHaveLength(1); + expect(await runEffects(deps, effects)).toBe(1); + expect(context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice")) + .toHaveLength(1); + expect(context.plan.chat.entries.at(-1)?.decision).toMatchObject({ + kind: "scoped-choice", + proposalId: proposal.id, + triggerEventId: proposal.id, + generation: 0, + sources: [proposal.source], + }); + expect(receipts).toEqual(new Set([effects[0]!.key])); + expect(errors.map(String)).toEqual(["Error: receipt unavailable"]); + + let agreementQuote = "yep, GitHub Apps for the spike."; + let firstAgreement: ConversationPlan.Event = { + id: "scoped-agreement-rob", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-m7", + author: { kind: "member", handle: "rob" }, + quote: agreementQuote, + start: 0, + end: agreementQuote.length, + role: "support", + }, + proposalId: proposal.id, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + state = applyEvent(state, firstAgreement); + let secondAgreement: ConversationPlan.Event = { + ...firstAgreement, + id: "scoped-agreement-ana", + observedThreadVersion: state.threads[0]!.version, + at: 4, + source: { + messageId: "scoped-m8", + author: { kind: "member", handle: "ana" }, + quote: agreementQuote, + start: 0, + end: agreementQuote.length, + role: "support", + }, + }; + state = applyEvent(state, secondAgreement); + context.plan.conversationPlan = state; + for (let agreement of [firstAgreement, secondAgreement]) { + if (agreement.type !== "scoped-choice.agreed") throw new Error("agreement missing"); + context.plan.chat.entries.push({ + id: agreement.source.messageId, + author: agreement.source.author, + text: agreement.source.quote, + ts: agreement.at, + }); + } + let newer = effectsFor([secondAgreement], state, undefined, context.plan.records); + let older = effectsFor([firstAgreement], state, undefined, context.plan.records); + expect(await runEffects(deps, newer)).toBe(1); + expect(await runEffects(deps, older)).toBe(1); + let notices = context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice"); + expect(notices).toHaveLength(1); + let decision = notices[0]?.decision; + if (decision?.kind !== "scoped-choice") throw new Error("scoped notice missing"); + expect(decision.sources).toEqual([ + proposal.source, + firstAgreement.source, + secondAgreement.source, + ]); + expect(state.events.filter(event => event.type === "scoped-choice.agreed")).toHaveLength(2); + expect(state.threads[0]?.status).toBe("exploring"); +}); diff --git a/apps/server/src/conversation-plan/cards-7.test.ts b/apps/server/src/conversation-plan/cards-7.test.ts new file mode 100644 index 00000000..567a7905 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-7.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { OPTION, plans, scopedNoticeFixture } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("scoped notices for distinct card options keep separate identities", async () => { + let context = await openPlan(); + plans.push(context.plan); + let otherOption = "01K0N4W3B7P27CBAEC7A8C8WEC"; + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [ + { id: OPTION, label: "GitHub Apps" }, + { id: otherOption, label: "Auth0" }, + ], + }); + let state = applyEvent(initialState(), { + id: "scoped-open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which auth system?", + }); + state = applyEvent(state, { + id: "scoped-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + questionnaireId: id, + }); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let errors: unknown[] = []; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + for ( + let [index, option] of [ + { id: OPTION, label: "GitHub Apps" }, + { id: otherOption, label: "Auth0" }, + ].entries() + ) { + let quote = `I'd pick ${option.label} for the spike;`; + let proposal: ConversationPlan.Event = { + id: `scoped-proposal-${index}`, + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: index + 2, + source: { + messageId: `scoped-message-${index}`, + author: { kind: "member", handle: index === 0 ? "mei" : "ana" }, + quote, + start: 0, + end: quote.length, + role: "support", + }, + cardId: id, + optionId: option.id, + label: option.label, + scope: "spike", + }; + state = applyEvent(state, proposal); + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: proposal.source.messageId, + author: proposal.source.author, + text: quote, + ts: proposal.at, + }); + expect(await runEffects(deps, effectsFor([proposal], state, undefined, context.plan.records))) + .toBe(1); + } + let notices = context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice"); + expect(notices).toHaveLength(2); + expect( + notices.map(entry => + entry.decision?.kind === "scoped-choice" ? entry.decision.optionId : undefined + ), + ).toEqual([OPTION, otherOption]); + expect(new Set(notices.map(entry => entry.id)).size).toBe(2); + expect(state.threads[0]?.status).toBe("exploring"); + expect(errors).toEqual([]); + let unrelated = notices[1]!; + let unchanged = structuredClone(unrelated.decision); + let quote = "I don't support that choice anymore."; + let opposition: ConversationPlan.Event = { + id: "first-option-proposer-opposes", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "first-option-opposition", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }; + state = applyEvent(state, opposition); + await Service.exclusive(context.plan, async () => { + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: opposition.source.messageId, + author: opposition.source.author, + text: quote, + ts: opposition.at, + }); + await Service.persistExclusive(context.plan); + }); + await runEffects(deps, effectsFor([opposition], state, undefined, context.plan.records)); + expect(context.plan.chat.entries.find(entry => entry.id === unrelated.id)?.decision) + .toEqual(unchanged); +}); + +test("a later non-support stance removes only its speaker from the scoped notice", async () => { + let fixture = await scopedNoticeFixture(true); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + expect(before?.decision).toMatchObject({ + sources: [fixture.proposal.source, fixture.agreement.source], + }); + let quote = "I oppose GitHub Apps now."; + await fixture.deliver({ + id: "rob-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "scoped-rob-opposes", + author: { kind: "member", handle: "rob" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + optionId: OPTION, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices).toHaveLength(1); + expect(notices[0]?.id).toBe(before?.id); + expect(notices[0]?.decision).toMatchObject({ sources: [fixture.proposal.source] }); + expect(fixture.errors).toEqual([]); +}); + +test("a proposer's later opposition leaves no actionable scoped Save prompt", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I oppose GitHub Apps now."; + await fixture.deliver({ + id: "mei-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-opposes", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + optionId: OPTION, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices.length === 0 || !fixture.state().threads[0]?.pendingScopedChoice).toBe(true); + expect(fixture.errors).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/cards-8.test.ts b/apps/server/src/conversation-plan/cards-8.test.ts new file mode 100644 index 00000000..ab0ef044 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-8.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test"; + +import { restoreState } from "./domain"; + +import { OPTION, scopedNoticeFixture } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("optionless non-support refreshes only the active supporter's scoped notice", async () => { + let fixture = await scopedNoticeFixture(true); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "rob-optionless-opposition", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "scoped-rob-optionless", + author: { kind: "member", handle: "rob" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices).toHaveLength(1); + expect(notices[0]?.id).toBe(before.id); + expect(notices[0]?.decision).toMatchObject({ sources: [fixture.proposal.source] }); + expect(fixture.state().events.at(-1)?.id).toBe("rob-optionless-opposition"); + expect(fixture.errors).toEqual([]); +}); + +test("optionless proposer non-support retires a scoped Save with no other support", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "mei-optionless-opposition", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-optionless", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices.length === 0 || !fixture.state().threads[0]?.pendingScopedChoice).toBe(true); + expect(fixture.state().events.at(-1)?.id).toBe("mei-optionless-opposition"); + expect(fixture.errors).toEqual([]); +}); + +test("legacy persisted optionless withdrawal still retires scoped Save on replay", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "mei-legacy-withdrawal", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-legacy", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let stored = structuredClone(fixture.state()); + let legacy = stored.events.at(-1); + if (!legacy || legacy.type !== "stance.changed") throw new Error("withdrawal missing"); + delete legacy.scopedProposalId; + let restored = restoreState(stored, fixture.context.plan.chat.entries); + expect(restored.events.at(-1)).not.toHaveProperty("scopedProposalId"); + expect(restored.threads[0]?.pendingScopedChoice).toBeUndefined(); +}); + +for ( + let scenario of [ + { + name: "an unrelated optionless objection by a supporter keeps scoped evidence", + supporter: true, + id: "rob-toolbar-objection", + messageId: "scoped-rob-toolbar", + actor: "rob", + quote: "I oppose making the toolbar blue.", + at: 4, + optionId: undefined, + }, + { + name: "an unrelated optionless objection by the proposer keeps scoped Save active", + supporter: false, + id: "mei-toolbar-objection", + messageId: "scoped-mei-toolbar", + actor: "mei", + quote: "I oppose making the toolbar blue.", + at: 3, + optionId: undefined, + }, + { + name: "an unlinked named-option objection keeps the scoped Save notice active", + supporter: false, + id: "mei-unlinked-named-objection", + messageId: "scoped-mei-unlinked-named", + actor: "mei", + quote: "I oppose GitHub Apps.", + at: 3, + optionId: OPTION, + }, + ] +) { + test(scenario.name, async () => { + let fixture = await scopedNoticeFixture(scenario.supporter); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + let previous = structuredClone(before.decision); + await fixture.deliver({ + id: scenario.id, + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: scenario.at, + source: { + messageId: scenario.messageId, + author: { kind: "member", handle: scenario.actor }, + quote: scenario.quote, + start: 0, + end: scenario.quote.length, + role: "objection", + }, + ...(scenario.optionId ? { optionId: scenario.optionId } : {}), + position: "oppose", + }); + expect(fixture.state().threads[0]?.pendingScopedChoice?.proposalId) + .toBe(fixture.proposal.id); + expect(fixture.context.plan.chat.entries.find(entry => entry.id === before.id)?.decision) + .toEqual(previous); + expect(fixture.errors).toEqual([]); + }); +} diff --git a/apps/server/src/conversation-plan/cards-9.test.ts b/apps/server/src/conversation-plan/cards-9.test.ts new file mode 100644 index 00000000..268be958 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-9.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; + +import { mirrorCard } from "./cards"; + +import { initialState } from "./domain"; + +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createProcessor } from "./service"; +import { OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("live card listeners mirror a reopen emitted inside the room lock without deadlock", async () => { + let context = await openPlan(); + plans.push(context.plan); + context.plan.conversationPlan = { ...initialState(), threads: [thread()] }; + let processor = createProcessor({ + plan: context.plan, + exclusive: action => Service.exclusive(context.plan, action), + persist: () => Service.persistExclusive(context.plan), + publish: () => {}, + active: () => true, + }); + let errors: unknown[] = []; + let off = Questions.listen(context.plan, () => { + void mirrorCard(context.plan, processor).catch(error => errors.push(error)); + }); + try { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let actor = "ana"; + let ws = { + data: { + get handle() { + return actor; + }, + client: "client", + room: "test", + }, + send() {}, + publish() {}, + } as never; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-1", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + actor = "ben"; + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m2"], + }), + ).toBe(true); + actor = "cy"; + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-2", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + for (let attempt = 0; attempt < 100 && context.plan.pendingCardActions.length; attempt++) { + await Bun.sleep(1); + } + expect(errors).toEqual([]); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlan.events.map(event => event.id)).toEqual([ + `card:${id}:decided:1`, + `card:${id}:reopened:1`, + `card:${id}:decided:2`, + ]); + expect(context.plan.conversationPlan.threads[0]!.decisionHistory.map(item => item.actor)) + .toEqual([ + { kind: "member", handle: "ana" }, + { kind: "member", handle: "cy" }, + ]); + } finally { + off(); + processor.stop(); + } +}); diff --git a/apps/server/src/conversation-plan/cards-domain-agreement.test.ts b/apps/server/src/conversation-plan/cards-domain-agreement.test.ts new file mode 100644 index 00000000..0de60a01 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain-agreement.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { + base, + julesAgrees, + mina, + optionId, + source, + withOption, + withPendingSettle, +} from "./cards-domain.test-fixtures"; + +describe("settle agreement authority", () => { + test("another member can agree with the pending option without deciding it", () => { + let state = applyInference(withPendingSettle(), { + ...base("agree", 3), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees); + expect(state.events.at(-1)?.type).toBe("settle.agreed"); + expect(state.threads[0]).toMatchObject({ + status: "exploring", + version: 4, + pendingSettle: { optionId, proposer: "mina" }, + }); + expect(state.threads[0].decision).toBeUndefined(); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("agreement requires another member and exactly the pending option", () => { + let self: Chat.Entry = { + ...julesAgrees, + id: "m4", + author: { kind: "member", handle: "mina" }, + }; + expect(() => + applyInference(withPendingSettle(), { + ...base("self", 3), + type: "settle.agreed", + source: source(self, "support"), + optionId, + }, self) + ).toThrow("agreement needs another member"); + expect(() => + applyInference(withPendingSettle(), { + ...base("different", 3), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId: "other-option", + }, julesAgrees) + ).toThrow("agreement names another option"); + expect(() => + applyInference(withOption(), { + ...base("no-proposal", 2), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("no pending proposal to settle"); + }); + + test("agreement stays classifier-sourced support by a real member", () => { + let state = withPendingSettle(); + expect(() => + applyInference(state, { + ...base("human-agree", 3, "human"), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("human events require authenticated correction"); + expect(() => + applyInference(state, { + ...base("planner-agree", 3, "planner"), + type: "settle.agreed", + source: source(julesAgrees, "support"), + optionId, + }, julesAgrees) + ).toThrow("agreement is inferred"); + expect(() => + applyInference(state, { + ...base("objection-agree", 3), + type: "settle.agreed", + source: source(julesAgrees, "objection"), + optionId, + }, julesAgrees) + ).toThrow("agreement source role disagrees"); + let agent: Chat.Entry = { + ...julesAgrees, + id: "agent-agree", + author: { kind: "agent" }, + }; + expect(() => + applyInference(state, { + ...base("agent-agree", 3), + type: "settle.agreed", + source: source(agent, "support"), + optionId, + }, agent) + ).toThrow("agreement needs another member"); + }); +}); + +describe("decision card thread authority", () => { + test("a settle proposal records its human proposer without deciding", () => { + let state = applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + expect(state.threads[0].pendingSettle).toEqual({ + optionId, + proposer: "mina", + messageId: "m2", + }); + expect(state.threads[0].status).toBe("exploring"); + expect(state.threads[0].decision).toBeUndefined(); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("a spike agreement binds the current accepted proposal without deciding", () => { + let cardId = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + let state = withOption(); + state = applyEvent(state, { + ...base("link-spike", state.threads[0]!.version), + type: "card.linked", + questionnaireId: cardId, + }); + let mei: Chat.Entry = { + id: "mei-spike", + author: { kind: "member", handle: "mei" }, + text: "I'd pick GitHub Apps for the spike;", + ts: 4, + }; + let proposal: ConversationPlan.Event = { + ...base("spike-proposal", state.threads[0]!.version), + type: "scoped-choice.proposed", + source: source(mei, "support"), + cardId, + optionId, + label: "GitHub Apps", + scope: "spike", + }; + state = applyInference(state, proposal, mei); + let rob: Chat.Entry = { + id: "rob-spike", + author: { kind: "member", handle: "rob" }, + text: "yep, GitHub Apps for the spike.", + ts: 5, + }; + let agreement: ConversationPlan.Event = { + ...base("spike-agreement", state.threads[0]!.version), + type: "scoped-choice.agreed", + source: source(rob, "support"), + proposalId: proposal.id, + cardId, + optionId, + label: "GitHub Apps", + scope: "spike", + }; + expect(() => applyInference(state, { ...agreement, proposalId: "prior-proposal" }, rob)) + .toThrow("scoped choice agreement does not match the current proposal"); + let agreed = applyInference(state, agreement, rob); + expect(agreed.events.at(-1)).toMatchObject({ + type: "scoped-choice.agreed", + proposalId: proposal.id, + }); + expect(agreed.threads[0]?.pendingScopedChoice?.proposalId).toBe(proposal.id); + expect(agreed.threads[0]?.pendingSettle).toBeUndefined(); + expect(agreed.threads[0]?.decision).toBeUndefined(); + expect(restoreState(JSON.parse(JSON.stringify(agreed))).threads).toEqual(agreed.threads); + }); + + test("settle proposals need a known option and a member's resolution quote", () => { + let state = withOption(); + let valid = { + ...base("settle", 2), + type: "settle.suggested" as const, + source: source(mina, "resolution"), + optionId, + }; + expect(() => + applyInference(state, { + ...valid, + optionId: "unknown", + }, mina) + ).toThrow("unknown settle option"); + let agent: Chat.Entry = { ...mina, author: { kind: "agent" } }; + expect(() => + applyInference(state, { + ...valid, + source: source(agent, "resolution"), + }, agent) + ).toThrow("human member required to settle"); + expect(() => + applyInference(state, { + ...valid, + source: source(mina, "option"), + }, mina) + ).toThrow("settle source role disagrees"); + let decided = applyEvent(state, { + ...base("card-decision", 2, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => applyInference(decided, { ...valid, observedThreadVersion: 3 }, mina)) + .toThrow("thread is not open for settling"); + }); +}); diff --git a/apps/server/src/conversation-plan/cards-domain-restore.test.ts b/apps/server/src/conversation-plan/cards-domain-restore.test.ts new file mode 100644 index 00000000..26a6bac6 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain-restore.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat } from "@chopin/protocol"; +import { applyInference, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { base, jules, mina, optionId, source, withOption } from "./cards-domain.test-fixtures"; + +describe("decision card thread authority", () => { + test("a thread links one decision card and restores that link", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + expect(state.threads[0].questionnaireId).toBe("01K0N4TR8K7JGM4R1J7PW4R8YJ"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + expect(() => + applyEvent(state, { + ...base("second-link", 3), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YK", + }) + ).toThrow("thread already has a card"); + }); + + test("a card decision clears the proposal, and a person can discard its thread", () => { + let state = applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + state = applyEvent(state, { + ...base("card-decision", 3, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(state.threads[0].pendingSettle).toBeUndefined(); + state = applyEvent(state, { + ...base("card-discard", 4, "human"), + type: "thread.discarded", + }); + expect(state.threads[0].status).toBe("discarded"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("sourced Planner chat contributions still apply and restore", () => { + let optionMessage: Chat.Entry = { + id: "agent-option", + author: { kind: "agent" }, + text: "Use a managed identity provider", + ts: 4, + }; + let reasonMessage: Chat.Entry = { + ...optionMessage, + id: "agent-reason", + text: "It handles account recovery", + }; + let state = applyInference(withOption(), { + ...base("classifier:0123456789abcdef01234567", 2, "planner"), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { + id: "classifier:89abcdef0123456789abcdef", + text: optionMessage.text, + authoring: "quoted", + }, + }, optionMessage); + state = applyInference(state, { + ...base("classifier:fedcba9876543210fedcba98", 3, "planner"), + type: "reason.added", + source: source(reasonMessage, "reason"), + contribution: { + id: "classifier:76543210fedcba9876543210", + text: reasonMessage.text, + authoring: "quoted", + targetId: optionId, + }, + }, reasonMessage); + expect(state.threads[0].contributions.slice(-2).map((item) => item.kind)).toEqual([ + "option", + "reason", + ]); + let saved = JSON.parse(JSON.stringify(state)); + let quotedOption: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + expect(restoreState(saved, [jules, quotedOption, optionMessage, reasonMessage]).threads) + .toEqual(saved.threads); + expect(() => + applyInference(state, { + ...base("classifier:111111111111111111111111", 4, "planner"), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { + id: "classifier:222222222222222222222222", + text: optionMessage.text, + authoring: "human-edited", + }, + }, optionMessage) + ).toThrow("inference cannot claim human wording"); + }); + + test("historical classifier decisions still replay and restore", () => { + let state = applyEvent(withOption(), { + ...base("historic-decision", 2), + type: "decision.recorded", + source: source(mina, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }); + state = applyEvent(state, { + ...base("historic-reopen", 3), + type: "decision.reopened", + source: source(mina, "reopening"), + explicit: true, + }); + let saved = JSON.parse(JSON.stringify(state)); + expect(replay(saved.events).threads).toEqual(saved.threads); + let quotedOption: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + expect(restoreState(saved, [jules, quotedOption, mina]).threads).toEqual(saved.threads); + expect(state.threads[0].decisionHistory).toHaveLength(1); + }); + + test("restore rejects tampered card links, proposals, and malformed new events", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + state = applyInference(state, { + ...base("settle", 3), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); + let saved = JSON.parse(JSON.stringify(state)); + let wrongLink = structuredClone(saved); + wrongLink.threads[0].questionnaireId = "other-card"; + expect(() => restoreState(wrongLink)).toThrow(/snapshot/i); + let wrongProposal = structuredClone(saved); + wrongProposal.threads[0].pendingSettle.optionId = "other-option"; + expect(() => restoreState(wrongProposal)).toThrow(/snapshot/i); + let malformedEvent = structuredClone(saved); + malformedEvent.events[2].source = source(mina, "resolution"); + expect(() => restoreState(malformedEvent)).toThrow(/unknown/i); + expect(() => + applyEvent(state, { + ...base("forged-discard", 4), + type: "thread.discarded", + }) + ).toThrow("only a person discards"); + }); + + test("the thread limit allows twenty and rejects a twenty-first", () => { + let state = initialState(); + for (let index = 0; index < 20; index++) { + let message = { ...jules, id: `question-${index}` }; + state = applyInference(state, { + ...base(`open-${index}`, 0), + threadId: `thread-${index}`, + type: "thread.opened", + source: source(message, "question"), + question: message.text, + }, message); + } + expect(state.threads).toHaveLength(20); + let message = { ...jules, id: "question-20" }; + expect(() => + applyInference(state, { + ...base("open-20", 0), + threadId: "thread-20", + type: "thread.opened", + source: source(message, "question"), + question: message.text, + }, message) + ).toThrow("conversation thread limit reached"); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); +}); diff --git a/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts b/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts new file mode 100644 index 00000000..b5c8970c --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain.test-fixtures.ts @@ -0,0 +1,80 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; + +export let optionId = "01K0N4W3B7P27CBAEC7A8C8WEA"; +export let jules: Chat.Entry = { + id: "m1", + author: { kind: "member", handle: "jules" }, + text: "What auth system should we use?", + ts: 1, +}; +export let mina: Chat.Entry = { + id: "m2", + author: { kind: "member", handle: "mina" }, + text: "Let's use GitHub Apps.", + ts: 2, +}; +export let julesAgrees: Chat.Entry = { + id: "m3", + author: { kind: "member", handle: "jules" }, + text: "Sounds good to me.", + ts: 3, +}; + +export function source( + message: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role, + }; +} + +export function base( + id: string, + version: number, + origin: "classifier" | "human" | "planner" = "classifier", +): ConversationPlan.EventBase { + return { + id, + threadId: "t1", + observedThreadVersion: version, + origin, + actor: origin === "human" + ? { kind: "member", handle: "mina" } + : origin === "planner" + ? { kind: "agent" } + : { kind: "classifier" }, + at: 3, + }; +} + +export function withOption(): ConversationPlan.State { + let state = applyInference(initialState(), { + ...base("open", 0), + type: "thread.opened", + source: source(jules, "question"), + question: jules.text, + }, jules); + let optionMessage: Chat.Entry = { ...jules, id: "option-message", text: "GitHub Apps" }; + return applyInference(state, { + ...base("option", 1), + type: "option.added", + source: source(optionMessage, "option"), + contribution: { id: optionId, text: optionMessage.text, authoring: "quoted" }, + }, optionMessage); +} + +export function withPendingSettle(): ConversationPlan.State { + return applyInference(withOption(), { + ...base("settle", 2), + type: "settle.suggested", + source: source(mina, "resolution"), + optionId, + }, mina); +} diff --git a/apps/server/src/conversation-plan/cards-domain.test.ts b/apps/server/src/conversation-plan/cards-domain.test.ts new file mode 100644 index 00000000..736c154a --- /dev/null +++ b/apps/server/src/conversation-plan/cards-domain.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { base, mina, optionId, source, withOption } from "./cards-domain.test-fixtures"; + +describe("decision card thread authority", () => { + test("new classifier and planner decisions cannot enter through inference", () => { + let state = withOption(); + for (let origin of ["classifier", "planner"] as const) { + let message = origin === "classifier" ? mina : { + ...mina, + author: { kind: "agent" as const }, + }; + expect(() => + applyInference(state, { + ...base(`decision-${origin}`, 2, origin), + type: "decision.recorded", + source: source(message, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }, message) + ).toThrow("decisions are recorded on the card"); + } + }); + + test("new inferred reopenings and resolution candidates cannot bypass the card", () => { + let decided = applyEvent(withOption(), { + ...base("old-decision", 2), + type: "decision.recorded", + source: source(mina, "resolution"), + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => + applyInference(decided, { + ...base("new-reopen", 3), + type: "decision.reopened", + source: source(mina, "reopening"), + explicit: true, + }, mina) + ).toThrow("decisions are recorded on the card"); + expect(() => + applyInference(withOption(), { + ...base("new-candidate", 2), + type: "candidate.proposed", + source: source(mina, "resolution"), + candidate: { id: "candidate", kind: "resolution", text: "GitHub Apps" }, + }, mina) + ).toThrow("decisions are recorded on the card"); + }); + + test("human and Planner options enter without a chat quote", () => { + let state = applyEvent(withOption(), { + ...base("human-option", 2, "human"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: "Build our own", + authoring: "human-edited", + }, + }); + state = applyEvent(state, { + ...base("planner-option", 3, "planner"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEC", + text: "Use an identity provider", + authoring: "scribe", + }, + }); + expect(state.threads[0].contributions.slice(-2)).toMatchObject([ + { actor: { kind: "member", handle: "mina" }, sources: [] }, + { actor: { kind: "agent" }, sources: [] }, + ]); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("source-free card options enforce ULIDs and origin-specific wording", () => { + let valid = { + ...base("card-option", 2, "human"), + type: "option.added", + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: "Build our own", + authoring: "human-edited", + }, + } as const satisfies ConversationPlan.Event; + let state = withOption(); + expect(() => + applyEvent(state, { + ...valid, + contribution: { ...valid.contribution, id: "not-a-ulid" }, + }) + ).toThrow("card option IDs must be ULIDs"); + expect(() => + applyEvent(state, { + ...valid, + contribution: { ...valid.contribution, authoring: "quoted" }, + }) + ).toThrow("card option authoring disagrees with origin"); + expect(() => + applyEvent(state, { + ...valid, + ...base("planner-incorrect", 2, "planner"), + }) + ).toThrow("card option authoring disagrees with origin"); + expect(() => + applyEvent(state, { + ...valid, + ...base("planner-bad-id", 2, "planner"), + contribution: { ...valid.contribution, id: "not-a-ulid", authoring: "scribe" }, + }) + ).toThrow("card option IDs must be ULIDs"); + expect(() => + applyEvent(state, { + ...valid, + ...base("card:planner-option:source", 2, "planner"), + source: source({ ...mina, author: { kind: "agent" } }, "option"), + }) + ).toThrow("only card options can omit a source"); + expect(() => + applyEvent(state, { + ...valid, + source: source(mina, "option"), + }) + ).toThrow("human action cannot claim a message quote"); + }); + + test("old correction actions cannot change a linked card's decision or status", () => { + let state = applyEvent(withOption(), { + ...base("link", 2), + type: "card.linked", + questionnaireId: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + }); + let correct = (change: ConversationPlan.CorrectionChange, current = state) => + applyCorrection( + current, + { + actionId: `correction-${change.kind}`, + threadId: "t1", + expectedVersion: current.threads[0].version, + change, + }, + { kind: "member", handle: "mina" }, + 4, + ); + expect(() => correct({ kind: "record-decision", text: "GitHub Apps", optionId })) + .toThrow("decisions are recorded on the card"); + let decided = applyEvent(state, { + ...base("card-decision", 3, "human"), + type: "decision.recorded", + text: "GitHub Apps", + optionId, + explicit: true, + }); + expect(() => correct({ kind: "set-status", status: "reopened" }, decided)) + .toThrow("decisions are recorded on the card"); + expect(() => correct({ kind: "edit", field: "decision", text: "Other choice" }, decided)) + .toThrow("decisions are recorded on the card"); + let candidate = applyInference(decided, { + ...base("reopening-candidate", 4), + type: "candidate.proposed", + source: source(mina, "reopening"), + candidate: { id: "reopen", kind: "reopening", text: "Reopen this" }, + }, mina); + expect(() => correct({ kind: "confirm-candidate", candidateId: "reopen" }, candidate)) + .toThrow("decisions are recorded on the card"); + }); +}); diff --git a/apps/server/src/conversation-plan/cards.test-fixtures.ts b/apps/server/src/conversation-plan/cards.test-fixtures.ts new file mode 100644 index 00000000..696b1621 --- /dev/null +++ b/apps/server/src/conversation-plan/cards.test-fixtures.ts @@ -0,0 +1,194 @@ +import { afterEach } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; + +import type { Processor } from "./service"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 helpers and cleanup. +export const CARD = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEB"; + +export let plans: Plan[] = []; + +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + plans = []; +}); + +export function thread( + status: ConversationPlan.Thread["status"] = "exploring", +): ConversationPlan.Thread { + return { + id: "thread-a", + question: "Which auth system?", + questionSources: [], + questionAuthoring: "quoted", + status, + contributions: [{ + id: OPTION, + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 4, + }; +} + +export function base() { + return { cardId: CARD, threadId: "thread-a", actor: "ana", at: 9 }; +} + +export function cardSource( + messageId: string, + handle: string, + role: "resolution" | "support" | "objection", +) { + return { + messageId, + author: { kind: "member" as const, handle }, + quote: messageId, + start: 0, + end: messageId.length, + role, + }; +} + +export async function scopedNoticeFixture(withAgreement: boolean) { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let state = applyEvent(initialState(), { + id: "scoped-open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which auth system?", + }); + state = applyEvent(state, { + id: "scoped-option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + contribution: { id: OPTION, text: "GitHub Apps", authoring: "scribe" }, + }); + state = applyEvent(state, { + id: "scoped-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + questionnaireId: id, + }); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let errors: unknown[] = []; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + let deliver = async (event: ConversationPlan.Event) => { + state = applyEvent(state, event); + await Service.exclusive(context.plan, async () => { + context.plan.conversationPlan = state; + if ("source" in event && event.source) { + context.plan.chat.entries.push({ + id: event.source.messageId, + author: event.source.author, + text: event.source.quote, + ts: event.at, + }); + } + await Service.persistExclusive(context.plan); + }); + await runEffects(deps, effectsFor([event], state, undefined, context.plan.records)); + }; + let quote = "I'd pick GitHub Apps for the spike;"; + let proposal: Extract = { + id: "scoped-proposal", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: { + messageId: "scoped-mei", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "support", + }, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + await deliver(proposal); + let agreementQuote = "yep, GitHub Apps for the spike."; + let agreement: Extract = { + id: "scoped-agreement", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-rob", + author: { kind: "member", handle: "rob" }, + quote: agreementQuote, + start: 0, + end: agreementQuote.length, + role: "support", + }, + proposalId: proposal.id, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + if (withAgreement) await deliver(agreement); + return { context, proposal, agreement, deliver, state: () => state, errors }; +} diff --git a/apps/server/src/conversation-plan/cards.ts b/apps/server/src/conversation-plan/cards.ts new file mode 100644 index 00000000..d0eb7c27 --- /dev/null +++ b/apps/server/src/conversation-plan/cards.ts @@ -0,0 +1,35 @@ +import * as Chat from "../chat/service"; + +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands, Processor } from "./service"; + +import { createCardTargets } from "./card-effect-targets"; +import { createCardSuggestion } from "./card-effect-suggestion"; +import { createCardPrompts } from "./card-effect-prompts"; +import { createScopedNotice } from "./card-effect-scoped"; + +export { mirroredEvent } from "./card-mirror"; +export { mirrorCard, wakeCardMirror } from "./card-mirror-runner"; +export { promptText, shouldPrompt } from "./card-prompts"; + +export function cardEffects( + plan: Plan, + server: Server, + roomId: string, + processor: Processor, + enqueueJob: EffectCommands["enqueueJob"] | undefined, + announcer: Chat.Announcer, +): EffectCommands { + return { + ...createCardTargets(plan, server, roomId, processor), + ...createCardSuggestion(plan, server, roomId), + ...createCardPrompts(plan, server, roomId, announcer), + ...createScopedNotice(plan, announcer), + ...(enqueueJob ? { enqueueJob } : {}), + report: error => console.error("[conversation-plan] card effect failed:", 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/correction-validation.ts b/apps/server/src/conversation-plan/correction-validation.ts new file mode 100644 index 00000000..12eb5d5f --- /dev/null +++ b/apps/server/src/conversation-plan/correction-validation.ts @@ -0,0 +1,90 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { id, knownKeys, record, text, version } from "./validation-fields"; + +export function assertCorrectionChange( + value: unknown, +): asserts value is ConversationPlan.CorrectionChange { + let change = record(value); + switch (change.kind) { + case "add-excerpt": + knownKeys(change, [ + "kind", + "messageId", + "start", + "end", + "contributionKind", + "targetOptionId", + ]); + id(change.messageId); + if ( + !Number.isSafeInteger(change.start) || !Number.isSafeInteger(change.end) + || (change.start as number) < 0 || (change.end as number) <= (change.start as number) + || (change.end as number) - (change.start as number) > 500 + ) throw new Error("invalid excerpt range"); + if (!["option", "reason", "constraint"].includes(change.contributionKind as string)) { + throw new Error("invalid excerpt contribution kind"); + } + if (change.targetOptionId !== undefined) id(change.targetOptionId); + break; + case "edit": + knownKeys(change, ["kind", "field", "contributionId", "text"]); + if (!["question", "contribution", "decision"].includes(change.field as string)) { + throw new Error("invalid edit field"); + } + text(change.text); + if (change.field === "contribution") id(change.contributionId); + else if ("contributionId" in change) { + throw new Error("unexpected contribution ID on question or decision edit"); + } + break; + case "move": + knownKeys(change, ["kind", "contributionId", "targetThreadId", "targetVersion"]); + id(change.contributionId); + id(change.targetThreadId); + version(change.targetVersion); + break; + case "set-status": + knownKeys(change, ["kind", "status"]); + if (!["exploring", "leaning", "reopened"].includes(change.status as string)) { + throw new Error("invalid status correction"); + } + break; + case "retarget-stance": + knownKeys(change, ["kind", "stanceId", "optionId"]); + id(change.stanceId); + if (change.optionId !== undefined) id(change.optionId); + break; + case "dismiss-stance": + knownKeys(change, ["kind", "stanceId"]); + id(change.stanceId); + break; + case "retarget-contribution": + knownKeys(change, ["kind", "contributionId", "targetId"]); + id(change.contributionId); + id(change.targetId); + break; + case "record-decision": + knownKeys(change, ["kind", "text", "optionId"]); + text(change.text); + if (change.optionId !== undefined) id(change.optionId); + break; + case "confirm-candidate": + case "reject-candidate": + knownKeys(change, ["kind", "candidateId"]); + id(change.candidateId); + break; + default: + throw new Error("invalid correction action"); + } +} + +export function assertCorrectionAction( + value: unknown, +): asserts value is ConversationPlan.CorrectionAction { + let action = record(value); + knownKeys(action, ["actionId", "threadId", "expectedVersion", "change"]); + id(action.actionId); + id(action.threadId); + version(action.expectedVersion); + assertCorrectionChange(action.change); +} diff --git a/apps/server/src/conversation-plan/corrections-contributions.test.ts b/apps/server/src/conversation-plan/corrections-contributions.test.ts new file mode 100644 index 00000000..eda808e5 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections-contributions.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState, replay, restoreState } from "./domain"; +import { buildTargetingRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { add, correct, message, open, stance } from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card contribution corrections", () => { + test("retargets reasons and constraints with exact wording and source attribution", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = add(state, "reason", "reason", "a"); + state = add(state, "constraint", "constraint", "t1"); + let original = structuredClone(state.threads[0].contributions.slice(-2)); + state = correct(state, "reason-to-thread", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "t1", + }); + state = correct(state, "constraint-to-b", { + kind: "retarget-contribution", + contributionId: "constraint", + targetId: "b", + }); + let changed = state.threads[0].contributions.slice(-2); + for (let index = 0; index < 2; index++) { + expect(changed[index]).toEqual({ + ...original[index], + targetId: index === 0 ? "t1" : "b", + targetEditedBy: "bob", + }); + } + expect(replay(state.events).threads).toEqual(state.threads); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual( + JSON.parse(JSON.stringify(state.threads)), + ); + let forged = JSON.parse(JSON.stringify(state)); + forged.threads[0].contributions.find((item: { id: string }) => item.id === "reason") + .targetEditedBy = "mallory"; + expect(() => restoreState(forged)).toThrow(/snapshot/i); + let invalid = structuredClone(state); + (invalid.events.at(-1) as any).change.extra = "unknown"; + expect(() => restoreState(invalid)).toThrow(/unknown/i); + }); + + test("rejects wrong contribution kinds, foreign targets, overlong IDs and stale versions", () => { + let state = add(open(initialState()), "option", "a"); + state = add(state, "reason", "reason", "a"); + state = open(state, "t2"); + state = add(state, "option", "foreign", undefined, "t2"); + let before = structuredClone(state); + for ( + let change of [ + { kind: "retarget-contribution", contributionId: "a", targetId: "t1" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "t2" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "foreign" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "a" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "x".repeat(201) }, + ] as ConversationPlan.CorrectionChange[] + ) { + expect(() => correct(state, "invalid-target", change)).toThrow(); + } + expect(() => + correct(state, "stale-target", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "t1", + }, state.threads[0].version - 1) + ).toThrow(/stale/i); + expect(state).toEqual(before); + }); + + test("later Jev context uses bounded corrected targets and current stances", () => { + let state = add(add(open(initialState()), "option", "a"), "reason", "reason", "t1"); + for (let index = 0; index < 15; index++) { + state = add(state, "reason", `other-${index}`, "t1"); + } + state = stance(state, "chat020-stance", "a"); + state = correct(state, "retarget-chat020", { + kind: "retarget-stance", + stanceId: "chat020-stance", + }); + state = correct(state, "retarget-reason", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "a", + }); + let next = message("next", "What follows from that?"); + let request = buildTargetingRequest(next, [], state.threads, extractQuotes(next.text)); + let context = (request.state as { + threads: Array<{ + stances: Array<{ participant: string; optionId?: string; position: string }>; + contributions: Array<{ id: string; targetId: string; relation: string }>; + }>; + }).threads[0]; + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + expect(context.stances).toEqual([{ + participant: "alice", + optionId: undefined, + position: "oppose", + }]); + expect(context.contributions).toHaveLength(4); + expect(context.contributions[0]).toMatchObject({ + id: "reason", + targetId: "a", + relation: "qualifies", + }); + expect(state.events.some((event) => event.id === "chat020-stance")).toBe(true); + }); +}); diff --git a/apps/server/src/conversation-plan/corrections-excerpts.test.ts b/apps/server/src/conversation-plan/corrections-excerpts.test.ts new file mode 100644 index 00000000..23dd0479 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections-excerpts.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import { + add, + bob, + correctExcerpt, + message, + open, + reviewedExcerpt, +} from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card excerpt corrections", () => { + test.each( + [ + { + kind: "option", + text: "Postmark handles email delivery.", + targetOptionId: undefined, + targetId: "t1", + }, + { kind: "reason", text: "SQS is operationally simple.", targetOptionId: "a", targetId: "a" }, + { + kind: "constraint", + text: "Must stay inside our VPC.", + targetOptionId: undefined, + targetId: "t1", + }, + ] as const, + )("adds a held $kind excerpt with original source and human correction actor", ({ + kind, + text, + targetOptionId, + targetId, + }) => { + let state = add(open(initialState()), "option", "a"); + let original = message("held-chat", `I think ${text} Please review.`); + let start = original.text.indexOf(text); + let end = start + text.length; + state = reviewedExcerpt(state, original, 0, original.text.length, "ignored"); + let action = { + actionId: `correct-${kind}`, + threadId: "t1", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: original.id, + start, + end, + contributionKind: kind, + targetOptionId, + }, + } as unknown as ConversationPlan.CorrectionAction; + let changed = correctExcerpt(state, action, original); + let added = changed.threads[0]!.contributions.at(-1)!; + let excerpt = text; + let sourceRef = { + messageId: original.id, + author: original.author, + quote: excerpt, + start, + end, + role: kind, + }; + expect(added).toMatchObject({ + kind, + text: excerpt, + targetId, + authoring: "quoted", + actor: bob, + sources: [sourceRef], + }); + expect(changed.events.at(-1)).toMatchObject({ + type: `${kind}.added`, + origin: "human", + actor: bob, + source: sourceRef, + contribution: { text: excerpt, targetId }, + }); + expect(changed.analysis.at(-1)?.outcomes?.[0]?.status).toBe("ignored"); + expect(changed.revision).toBe(state.revision + 1); + }); + + test("replaying an excerpt correction action ID is idempotent", () => { + let state = add(open(initialState()), "option", "a"); + let original = message("held-retry", "SQS is operationally simple."); + state = reviewedExcerpt(state, original, 0, original.text.length); + let action = { + actionId: "correct-retry", + threadId: "t1", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: original.id, + start: 0, + end: original.text.length, + contributionKind: "reason", + targetOptionId: "a", + }, + } as unknown as ConversationPlan.CorrectionAction; + let changed = correctExcerpt(state, action, original); + expect(correctExcerpt(changed, action, original)).toBe(changed); + expect(changed.events.filter(event => event.origin === "human")).toHaveLength(1); + }); +}); diff --git a/apps/server/src/conversation-plan/corrections.test-fixtures.ts b/apps/server/src/conversation-plan/corrections.test-fixtures.ts new file mode 100644 index 00000000..db2c26a1 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections.test-fixtures.ts @@ -0,0 +1,128 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference } from "./domain"; +import { QUESTION_SET_VERSION } from "./questions"; + +// Exact actor/helper declarations: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. +export let alice = { kind: "member", handle: "alice" } as const; +export let bob = { kind: "member", handle: "bob" } as const; + +export function message(id: string, text: string): Chat.Entry { + return { id, text, author: alice, ts: 1 }; +} + +export function source( + entry: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote: entry.text, + start: 0, + end: entry.text.length, + role, + }; +} + +export function open(state: ConversationPlan.State, threadId = "t1"): ConversationPlan.State { + let entry = message(`open-${threadId}`, `Which option for ${threadId}?`); + return applyInference(state, { + id: `open-event-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }, entry); +} + +export function add( + state: ConversationPlan.State, + kind: "option" | "reason" | "constraint", + id: string, + targetId?: string, + threadId = "t1", +): ConversationPlan.State { + let entry = message(`message-${id}`, `Words for ${id}`); + return applyInference(state, { + id: `event-${id}`, + type: `${kind}.added`, + threadId, + observedThreadVersion: state.threads.find((thread) => thread.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: source(entry, kind), + contribution: { id, text: entry.text, authoring: "quoted", targetId, relation: "qualifies" }, + }, entry); +} + +export function stance( + state: ConversationPlan.State, + id: string, + optionId?: string, +): ConversationPlan.State { + let entry = message(`message-${id}`, `I object to ${optionId ?? "the thread"}.`); + return applyInference(state, { + id, + type: "stance.changed", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source(entry, "objection"), + optionId, + position: "oppose", + }, entry); +} + +export function correct( + state: ConversationPlan.State, + actionId: string, + change: ConversationPlan.CorrectionChange, + expectedVersion = state.threads[0].version, +): ConversationPlan.State { + return applyCorrection(state, { actionId, threadId: "t1", expectedVersion, change }, bob, 10); +} + +export function reviewedExcerpt( + state: ConversationPlan.State, + entry: Chat.Entry, + start: number, + end: number, + status: "review" | "ignored" = "review", +): ConversationPlan.State { + return { + ...state, + revision: state.revision + 1, + analysis: [...state.analysis, { + messageId: entry.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "jev-test", + status: "unlinked", + passes: [], + eventIds: [], + outcomes: [{ start, end, status, gate: "unclassified excerpt", eventIds: [] }], + }], + }; +} + +export function correctExcerpt( + state: ConversationPlan.State, + action: ConversationPlan.CorrectionAction, + entry: Chat.Entry, +): ConversationPlan.State { + let apply = applyCorrection as unknown as ( + state: ConversationPlan.State, + action: ConversationPlan.CorrectionAction, + actor: typeof bob, + at: number, + messages: readonly Chat.Entry[], + ) => ConversationPlan.State; + return apply(state, action, bob, 10, [entry]); +} diff --git a/apps/server/src/conversation-plan/corrections.test.ts b/apps/server/src/conversation-plan/corrections.test.ts new file mode 100644 index 00000000..7544aa20 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { add, bob, correct, open, stance } from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card target corrections", () => { + test("retargets a sourced objection while keeping its speaker, quote and history", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = stance(state, "chat020-stance", "a"); + let original = structuredClone(state.threads[0].stances[0]); + let version = state.threads[0].version; + let changed = correct(state, "retarget-chat020", { + kind: "retarget-stance", + stanceId: original.id, + optionId: "b", + }); + let thread = changed.threads[0]; + expect(thread.version).toBe(version + 1); + expect(changed.revision).toBe(state.revision + 1); + expect(thread.status).toBe("exploring"); + expect(thread.stances).toEqual([{ + ...original, + id: "human:bob:retarget-chat020", + optionId: "b", + corrects: original.id, + correctedBy: "bob", + }]); + expect(thread.stanceHistory).toEqual([original, thread.stances[0]]); + expect(thread.stances[0].participant).toBe("alice"); + expect(thread.stances[0].sources).toEqual(original.sources); + expect(changed.events.at(-1)).toMatchObject({ + type: "card.corrected", + actor: bob, + change: { kind: "retarget-stance", stanceId: original.id, optionId: "b" }, + }); + expect("source" in changed.events.at(-1)!).toBe(false); + let saved = JSON.parse(JSON.stringify(changed)); + let restored = restoreState(saved); + expect(restored.threads).toEqual(saved.threads); + expect(replay(restored.events).threads).toEqual(restored.threads); + let forged = structuredClone(saved); + forged.threads[0].stances[0].correctedBy = "mallory"; + expect(() => restoreState(forged)).toThrow(/snapshot/i); + let retry = { + actionId: "retarget-chat020", + threadId: "t1", + expectedVersion: version, + change: { kind: "retarget-stance", stanceId: original.id, optionId: "b" } as const, + }; + expect(applyCorrection(restored, retry, bob, 11)).toBe(restored); + expect(() => + applyCorrection( + restored, + { + ...retry, + change: { ...retry.change, optionId: "a" }, + }, + bob, + 11, + ) + ).toThrow(/collision/i); + }); + + test("dismisses a mistaken stance without inventing a neutral vote or reopening", () => { + let state = stance(add(open(initialState()), "option", "a"), "wrong-objection", "a"); + let original = structuredClone(state.threads[0].stances[0]); + state = correct(state, "dismiss-wrong", { kind: "dismiss-stance", stanceId: original.id }); + expect(state.threads[0].stances).toEqual([]); + expect(state.threads[0].stanceHistory).toEqual([original]); + expect(state.threads[0].status).toBe("exploring"); + expect(restoreState(JSON.parse(JSON.stringify(state))).events.at(-1)).toMatchObject({ + type: "card.corrected", + actor: bob, + }); + state = applyEvent(state, { + id: "decision", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 12, + text: "Use a", + optionId: "a", + explicit: true, + }); + expect(state.threads[0].status).toBe("decided"); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("correction of an objection leaves an existing decision in place", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = applyEvent(state, { + id: "card-decision", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 10, + text: "Choose a", + optionId: "a", + explicit: true, + }); + state = stance(state, "late-objection", "a"); + let decision = structuredClone(state.threads[0].decision); + state = correct(state, "move-objection", { + kind: "retarget-stance", + stanceId: "late-objection", + optionId: "b", + }); + expect(state.threads[0].status).toBe("decided"); + expect(state.threads[0].decision).toEqual(decision); + }); + + test("rejects stale stance identities, absent options, same targets and target collisions", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = stance(state, "old", "a"); + state = stance(state, "current", "a"); + state = stance(state, "on-b", "b"); + let before = structuredClone(state); + for ( + let change of [ + { kind: "retarget-stance", stanceId: "old", optionId: "b" }, + { kind: "dismiss-stance", stanceId: "old" }, + { kind: "retarget-stance", stanceId: "current", optionId: "missing" }, + { kind: "retarget-stance", stanceId: "current", optionId: "a" }, + { kind: "retarget-stance", stanceId: "current", optionId: "b" }, + ] as ConversationPlan.CorrectionChange[] + ) { + expect(() => + correct( + state, + `reject-${change.kind}-${"optionId" in change ? change.optionId : "old"}`, + change, + ) + ) + .toThrow(); + } + expect(() => + correct( + state, + "stale", + { kind: "dismiss-stance", stanceId: "current" }, + state.threads[0].version - 1, + ) + ).toThrow(/stale/i); + expect(state).toEqual(before); + expect(() => + correct(state, "unknown-field", { + kind: "dismiss-stance", + stanceId: "current", + extra: "forged", + } as unknown as ConversationPlan.CorrectionChange) + ).toThrow(/unknown/i); + }); +}); diff --git a/apps/server/src/conversation-plan/domain-analysis.test.ts b/apps/server/src/conversation-plan/domain-analysis.test.ts new file mode 100644 index 00000000..542f68b6 --- /dev/null +++ b/apps/server/src/conversation-plan/domain-analysis.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, completeAnalysis, enqueue, initialState, restoreState } from "./domain"; +import { alice, bob, opened } from "./domain.test-fixtures"; + +describe("conversation plan events", () => { + test("message completion applies events and bounded debug with queue removal", () => { + let queued = enqueue(initialState(), "m1"); + let finished = completeAnalysis(queued, "m1", [opened()], alice, { + questionSetVersion: "v1", + modelVersion: "jev-test", + status: "applied", + passes: [{ stage: "triage", answers: { question: { type: "noul", noul: 0.9 } } }], + policyGate: "clear question", + latencyMs: 12, + }); + expect(finished.queue).toEqual([]); + expect(finished.analysis[0].eventIds).toEqual(["m1:0:thread.opened:v1"]); + expect(finished.threads).toHaveLength(1); + let bad = { ...opened(), id: "bad", observedThreadVersion: 3 }; + expect(() => + completeAnalysis(queued, "m1", [bad], alice, { + questionSetVersion: "v1", + modelVersion: "jev-test", + status: "applied", + passes: [], + }) + ).toThrow(); + expect(queued.threads).toHaveLength(0); + }); + + test("completed debug data is detached from the caller's mutable response", () => { + let debug: Omit = { + questionSetVersion: "v1", + modelVersion: "jev-test", + status: "unlinked", + passes: [], + }; + let finished = completeAnalysis(enqueue(initialState(), "m1"), "m1", [], alice, debug); + debug.passes.push({ stage: "triage", answers: {} }); + expect(finished.analysis[0].passes).toEqual([]); + }); + + test("absent state is initial, malformed present snapshots fail closed", () => { + expect(restoreState(undefined)).toEqual(initialState()); + expect(() => + restoreState({ + schemaVersion: 1, + revision: 0, + events: [], + threads: [{}], + queue: [], + analysis: [], + }) + ).toThrow(); + let state = applyInference(initialState(), opened(), alice); + expect(() => restoreState(state, [bob])).toThrow(/missing/i); + }); + + test("queue deduplicates message IDs across retry and restore", () => { + let state = enqueue(initialState(), "m1"); + expect(enqueue(state, "m1")).toEqual(state); + expect(restoreState(structuredClone(state)).queue).toEqual([{ + messageId: "m1", + status: "pending", + attempts: 0, + }]); + }); + + test("restore rejects unbounded probability labels in debug data", () => { + let state = initialState(); + state.analysis.push({ + messageId: "m1", + questionSetVersion: "v1", + modelVersion: "jev-test", + status: "unlinked", + passes: [{ stage: "triage", answers: { ["x".repeat(1000)]: { type: "noul", noul: 1 } } }], + eventIds: [], + }); + expect(() => restoreState(state)).toThrow(); + }); +}); diff --git a/apps/server/src/conversation-plan/domain-analysis.ts b/apps/server/src/conversation-plan/domain-analysis.ts new file mode 100644 index 00000000..a1178480 --- /dev/null +++ b/apps/server/src/conversation-plan/domain-analysis.ts @@ -0,0 +1,97 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain-initial"; +import { applyEvent } from "./events"; +import { validateSource } from "./sources"; +import { assertStateShape, MAX_ANALYSIS, MAX_QUEUE } from "./validation"; + +type State = ConversationPlan.State; +type Event = ConversationPlan.Event; + +export function enqueue(state: State, messageId: string): State { + if (!messageId || messageId.length > 200) throw new Error("invalid message ID"); + if (state.queue.some((item) => item.messageId === messageId)) return state; + if (state.queue.length >= MAX_QUEUE) throw new Error("conversation analysis queue is full"); + return { + ...state, + revision: state.revision + 1, + queue: [...state.queue, { messageId, status: "pending", attempts: 0 }], + }; +} + +export function retryMessage(state: State, messageId: string): State { + let index = state.queue.findIndex((item) => item.messageId === messageId); + if (index < 0 || state.queue[index].status !== "failed") throw new Error("message is not failed"); + let queue = state.queue.map((item, i) => + i === index + ? { messageId, status: "pending" as const, attempts: item.attempts + 1 } + : item + ); + return { ...state, revision: state.revision + 1, queue }; +} + +export function replay(events: readonly Event[]): State { + let state = initialState(); + for (let event of events) state = applyEvent(state, event); + return state; +} + +export function applyInference(state: State, event: Event, message: Chat.Entry): State { + if (state.events.some((accepted) => accepted.id === event.id)) return state; + if (event.origin === "human") throw new Error("human events require authenticated correction"); + if ( + event.type === "decision.recorded" || event.type === "decision.reopened" + || (event.type === "candidate.proposed" && event.candidate.kind === "resolution") + ) { + throw new Error("decisions are recorded on the card"); + } + if (!("source" in event) || !event.source) throw new Error("inference needs a source"); + validateSource(event.source, message); + return applyEvent(state, event); +} + +export function completeAnalysis( + state: State, + messageId: string, + events: readonly Event[], + message: Chat.Entry, + analysis: Omit, +): State { + let queued = state.queue.find((item) => item.messageId === messageId); + if (message.id !== messageId || !queued || queued.status === "failed") { + throw new Error("conversation analysis message is not queued"); + } + if (events.length > 12) throw new Error("too many events from one message"); + if (!["applied", "unlinked", "failed"].includes(analysis.status)) { + throw new Error("analysis is not terminal"); + } + if (analysis.status !== "applied" && events.length > 0) { + throw new Error("non-applied analysis has events"); + } + let next = state; + for (let event of events) { + if (!("source" in event) || event.source?.messageId !== messageId) { + throw new Error("analysis event belongs to another message"); + } + next = applyInference(next, event, message); + } + let eventIds = events.filter((event) => next.events.some((accepted) => accepted.id === event.id)) + .map((event) => event.id); + let record: ConversationPlan.AnalysisRecord = { + ...structuredClone(analysis), + messageId, + eventIds, + }; + let history = [...next.analysis.filter((item) => item.messageId !== messageId), record].slice( + -MAX_ANALYSIS, + ); + let queue = analysis.status === "failed" + ? next.queue.map((item) => + item.messageId === messageId + ? { ...item, status: "failed" as const, error: analysis.error?.slice(0, 300) } + : item + ) + : next.queue.filter((item) => item.messageId !== messageId); + let completed = { ...next, revision: next.revision + 1, queue, analysis: history }; + assertStateShape(completed); + return completed; +} diff --git a/apps/server/src/conversation-plan/domain-corrections.test.ts b/apps/server/src/conversation-plan/domain-corrections.test.ts new file mode 100644 index 00000000..afddaac7 --- /dev/null +++ b/apps/server/src/conversation-plan/domain-corrections.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { alice, bob, opened, option, source } from "./domain.test-fixtures"; + +describe("conversation plan events", () => { + test("restores JSON-persisted optional fields after decide, reopen and status correction", () => { + let state = applyInference(initialState(), opened(), alice); + state = applyEvent(state, { + id: "m7:0:decision.recorded:v1", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: 1, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 7, + text: "Use an outline", + explicit: true, + }); + state = applyEvent(state, { + id: "m8:0:decision.reopened:v1", + type: "decision.reopened", + threadId: "t1", + observedThreadVersion: 2, + origin: "human", + actor: { kind: "member", handle: "bob" }, + at: 8, + explicit: true, + }); + state = applyCorrection( + state, + { + actionId: "status-exploring", + threadId: "t1", + expectedVersion: 3, + change: { kind: "set-status", status: "exploring" }, + }, + { kind: "member", handle: "alice" }, + 9, + ); + let saved = JSON.parse(JSON.stringify(state)); + let restored = restoreState(saved, [alice]); + expect(restored).toEqual(saved); + }); + + test("human wording survives later classifier work and a wrong target can move", () => { + let state = applyInference(initialState(), opened(), alice); + state = applyInference(state, option(state), bob); + let secondMessage = { ...alice, id: "m5", text: "What about blank canvas?" }; + let second: ConversationPlan.Event = { + ...opened(), + id: "m5:0:thread.opened:v1", + threadId: "t2", + at: 5, + source: source(secondMessage, "question"), + question: "What about blank canvas?", + }; + state = applyInference(state, second, secondMessage); + state = applyCorrection( + state, + { + actionId: "edit-o1", + threadId: "t1", + expectedVersion: 2, + change: { + kind: "edit", + field: "contribution", + contributionId: "o1", + text: "Optional starter outline", + }, + }, + { kind: "member", handle: "alice" }, + 6, + ); + expect(state.threads[0].contributions[0].authoring).toBe("human-edited"); + let stale = option(state); + stale.id = "m2:retry"; + stale.observedThreadVersion = 2; + expect(() => applyInference(state, stale, bob)).toThrow(/stale/i); + state = applyCorrection( + state, + { + actionId: "move-o1", + threadId: "t1", + expectedVersion: state.threads[0].version, + change: { kind: "move", contributionId: "o1", targetThreadId: "t2", targetVersion: 1 }, + }, + { kind: "member", handle: "alice" }, + 7, + ); + expect(state.threads[0].contributions).toHaveLength(0); + expect(state.threads[1].contributions[0].text).toBe("Optional starter outline"); + expect(state.threads[1].contributions[0].sources[0].messageId).toBe("m2"); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("legacy resolution candidates replay, while new confirmations are refused", () => { + let state = applyInference(initialState(), opened(), alice); + let candidateMessage = { ...bob, id: "m6", text: "Maybe we agreed on an outline?" }; + let candidate: ConversationPlan.Event = { + id: "m6:0:candidate.proposed:v1", + type: "candidate.proposed", + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: source(candidateMessage, "resolution"), + candidate: { id: "c1", kind: "resolution", text: "Use an outline" }, + }; + state = applyEvent(state, candidate); + state = applyCorrection( + state, + { + actionId: "reject-c1", + threadId: "t1", + expectedVersion: 2, + change: { kind: "reject-candidate", candidateId: "c1" }, + }, + { kind: "member", handle: "alice" }, + 7, + ); + expect(state.threads[0].candidates[0].status).toBe("rejected"); + expect(state.threads[0].status).toBe("exploring"); + let second: ConversationPlan.Event = { + ...candidate, + id: "m6:1:candidate.proposed:v1", + observedThreadVersion: state.threads[0].version, + candidate: { id: "c2", kind: "resolution", text: "Use an outline" }, + }; + state = applyEvent(state, second); + expect(() => + applyCorrection( + state, + { + actionId: "confirm-c2", + threadId: "t1", + expectedVersion: state.threads[0].version, + change: { kind: "confirm-candidate", candidateId: "c2" }, + }, + { kind: "member", handle: "alice" }, + 8, + ) + ) + .toThrow("decisions are recorded on the card"); + state = applyEvent(state, { + id: "historic-confirm-c2", + type: "candidate.confirmed", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 8, + candidateId: "c2", + }); + expect(state.threads[0].status).toBe("decided"); + expect(state.threads[0].decision?.actor).toEqual({ kind: "member", handle: "alice" }); + expect(state.threads[0].decision?.sources[0].messageId).toBe("m6"); + expect(replay(state.events).threads).toEqual(state.threads); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads); + }); + + test("stale corrections fail and stable action IDs make retries idempotent", () => { + let state = applyInference(initialState(), opened(), alice); + let action: ConversationPlan.CorrectionAction = { + actionId: "edit-question", + threadId: "t1", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Should we offer an outline?" }, + }; + let changed = applyCorrection(state, action, { kind: "member", handle: "bob" }, 3); + expect(changed.threads[0].question).toBe("Should we offer an outline?"); + expect(applyCorrection(changed, action, { kind: "member", handle: "bob" }, 4)).toEqual(changed); + expect(() => + applyCorrection(changed, { ...action, actionId: "another-action" }, { + kind: "member", + handle: "bob", + }, 4) + ).toThrow(/stale/i); + }); +}); diff --git a/apps/server/src/conversation-plan/domain-corrections.ts b/apps/server/src/conversation-plan/domain-corrections.ts new file mode 100644 index 00000000..904a495f --- /dev/null +++ b/apps/server/src/conversation-plan/domain-corrections.ts @@ -0,0 +1,135 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { isDeepStrictEqual } from "node:util"; +import { ulid } from "@chopin/dialect"; +import { applyEvent } from "./events"; +import { validateSource } from "./sources"; +import { assertCorrectionAction } from "./validation"; + +type State = ConversationPlan.State; +type Event = ConversationPlan.Event; +type Member = Extract; + +export function applyCorrection( + state: State, + request: ConversationPlan.CorrectionAction, + actor: Member, + at: number, + messages?: ReadonlyMap | readonly Chat.Entry[], +): State { + assertCorrectionAction(request); + if (actor.kind !== "member" || !actor.handle) { + throw new Error("correction requires a human member"); + } + let id = `human:${actor.handle}:${request.actionId}`; + let base: ConversationPlan.EventBase = { + id, + threadId: request.threadId, + observedThreadVersion: request.expectedVersion, + origin: "human", + actor, + at, + }; + let change = request.change; + let existing = state.events.find((accepted) => accepted.id === id); + let event: Event; + switch (change.kind) { + case "add-excerpt": { + let saved = Array.isArray(messages) + ? messages.find(item => item.id === change.messageId) + : (messages as ReadonlyMap | undefined)?.get(change.messageId); + if (!saved) throw new Error("saved excerpt message is missing"); + let quote = saved.text.slice(change.start, change.end); + let source: ConversationPlan.SourceRef = { + messageId: change.messageId, + author: saved.author as ConversationPlan.SourceAuthor, + quote, + start: change.start, + end: change.end, + role: change.contributionKind, + }; + validateSource(source, saved); + if (!quote.trim() || quote.length > 500) throw new Error("invalid excerpt quote"); + let outcome = state.analysis.find(item => item.messageId === change.messageId) + ?.outcomes?.find(item => + ["review", "ignored"].includes(item.status) + && item.start <= change.start && item.end >= change.end + ); + if (!outcome && !existing) throw new Error("excerpt has no held analysis outcome"); + let target = state.threads.find(item => item.id === request.threadId); + if (!target || ["decided", "discarded"].includes(target.status) && !existing) { + throw new Error("excerpt target thread is not open"); + } + if (target.version !== request.expectedVersion && !existing) { + throw new Error("stale conversation thread version"); + } + if ( + !existing && change.targetOptionId && ( + change.contributionKind === "option" + || !target.contributions.some(item => + item.kind === "option" && item.id === change.targetOptionId + ) + ) + ) throw new Error("invalid excerpt target option"); + if ( + !existing + && state.events.some(item => + item.threadId === request.threadId && "source" in item && item.source + && item.source.messageId === change.messageId + && item.source.start === change.start && item.source.end === change.end + ) + ) throw new Error("duplicate excerpt source"); + let contributionId = existing && "contribution" in existing + ? existing.contribution.id + : ulid(); + event = { + ...base, + type: `${change.contributionKind}.added`, + source, + contribution: { + id: contributionId, + text: quote, + authoring: "quoted", + targetId: change.targetOptionId ?? request.threadId, + }, + }; + break; + } + case "record-decision": + event = { + ...base, + type: "decision.recorded", + text: change.text, + optionId: change.optionId, + explicit: true, + }; + break; + case "confirm-candidate": + event = { ...base, type: "candidate.confirmed", candidateId: change.candidateId }; + break; + case "reject-candidate": + event = { ...base, type: "candidate.rejected", candidateId: change.candidateId }; + break; + default: + event = { ...base, type: "card.corrected", change }; + } + if (existing) { + let saved = JSON.parse(JSON.stringify({ ...existing, at: 0 })); + let submitted = JSON.parse(JSON.stringify({ ...event, at: 0 })); + if (!isDeepStrictEqual(saved, submitted)) throw new Error("human action ID collision"); + return state; + } + let thread = state.threads.find((item) => item.id === request.threadId); + if ( + change.kind === "record-decision" + || (change.kind === "confirm-candidate" && ( + thread?.questionnaireId + || thread?.candidates.some((item) => + item.id === change.candidateId && item.kind === "resolution" + ) + )) + || (thread?.questionnaireId && ( + change.kind === "set-status" || (change.kind === "edit" && change.field === "decision") + )) + ) throw new Error("decisions are recorded on the card"); + return applyEvent(state, event); +} diff --git a/apps/server/src/conversation-plan/domain-initial.ts b/apps/server/src/conversation-plan/domain-initial.ts new file mode 100644 index 00000000..649bec33 --- /dev/null +++ b/apps/server/src/conversation-plan/domain-initial.ts @@ -0,0 +1,15 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +type State = ConversationPlan.State; + +export function initialState(): State { + return { + schemaVersion: 1, + revision: 0, + events: [], + threads: [], + queue: [], + analysis: [], + researchOffers: [], + }; +} diff --git a/apps/server/src/conversation-plan/domain-research.ts b/apps/server/src/conversation-plan/domain-research.ts new file mode 100644 index 00000000..9dbdc9db --- /dev/null +++ b/apps/server/src/conversation-plan/domain-research.ts @@ -0,0 +1,88 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { isDeepStrictEqual } from "node:util"; +import { validateSource } from "./sources"; +import { assertResearchOfferShape, assertStateShape, MAX_RESEARCH_OFFERS } from "./validation"; +import { namesStaleResearchOption, taskMatches } from "./research-snapshots"; + +type State = ConversationPlan.State; + +export function offerResearch( + state: State, + proposal: Omit, + message: Chat.Entry, +): State { + if ( + !proposal || typeof proposal !== "object" || Array.isArray(proposal) + || Object.keys(proposal).some((key) => + !["id", "needId", "contextId", "source", "brief", "threadId", "task"].includes(key) + ) + ) throw new Error("invalid research offer proposal"); + let offer: ConversationPlan.ResearchOffer = { + ...structuredClone(proposal), + status: "offered", + }; + assertResearchOfferShape(offer); + validateSource({ ...offer.source, role: "support" }, message); + let current = state.researchOffers ?? []; + let existing = current.find((item) => item.id === offer.id); + if (existing) { + let { status: _status, action: _action, ...identity } = existing; + if (isDeepStrictEqual(identity, proposal)) return state; + throw new Error("research offer ID already has different content"); + } + if (offer.threadId && !state.threads.some((thread) => thread.id === offer.threadId)) { + throw new Error("research offer thread is missing"); + } + if (offer.task && !taskMatches(state, offer.task)) { + throw new Error("research task options do not match the current thread"); + } + if (offer.task && namesStaleResearchOption(state, offer.task, offer.source.quote)) { + throw new Error("research task source names a stale option"); + } + if (offer.task && offer.task.observedEventCount !== state.events.length) { + throw new Error("research task event prefix is stale"); + } + if (current.length >= MAX_RESEARCH_OFFERS) throw new Error("research offers are full"); + let next = { + ...state, + revision: state.revision + 1, + researchOffers: [...current, offer], + }; + assertStateShape(next); + return next; +} + +export function actOnResearchOffer( + state: State, + offerId: string, + action: ConversationPlan.ResearchAction, +): State { + let current = state.researchOffers ?? []; + let index = current.findIndex((item) => item.id === offerId); + if (index < 0) throw new Error("research offer is missing"); + let offer = current[index]; + let status: ConversationPlan.ResearchOffer["status"] = action.kind === "research" + ? "accepted" + : "dismissed"; + assertResearchOfferShape({ ...offer, status, action }); + if (offer.status !== "offered") { + if ( + offer.action?.id === action.id && offer.action.kind === action.kind + && isDeepStrictEqual(offer.action.actor, action.actor) + && offer.action.principalId === action.principalId + ) return state; + throw new Error("research offer already has a terminal action"); + } + if (current.some((item) => item.action?.id === action.id)) { + throw new Error("research action ID already used"); + } + let next: State = { + ...state, + revision: state.revision + 1, + researchOffers: current.map((item, position) => + position === index ? { ...item, status, action: structuredClone(action) } : item + ), + }; + assertStateShape(next); + return next; +} diff --git a/apps/server/src/conversation-plan/domain.test-fixtures.ts b/apps/server/src/conversation-plan/domain.test-fixtures.ts new file mode 100644 index 00000000..9a4492f7 --- /dev/null +++ b/apps/server/src/conversation-plan/domain.test-fixtures.ts @@ -0,0 +1,60 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +export let alice: Chat.Entry = { + id: "m1", + author: { kind: "member", handle: "alice" }, + text: "Should we start with an outline?", + ts: 1, +}; +export let bob: Chat.Entry = { + id: "m2", + author: { kind: "member", handle: "bob" }, + text: "I prefer an optional outline.", + ts: 2, +}; + +export function source( + message: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role, + }; +} + +export function opened(): Extract { + return { + id: "m1:0:thread.opened:v1", + type: "thread.opened", + threadId: "t1", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(alice, "question"), + question: "Should we start with an outline?", + }; +} + +export function option(state: ConversationPlan.State): ConversationPlan.Event { + return { + id: "m2:0:option.added:v1", + type: "option.added", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: source(bob, "option"), + contribution: { + id: "o1", + text: "Optional outline", + authoring: "scribe", + }, + }; +} diff --git a/apps/server/src/conversation-plan/domain.test.ts b/apps/server/src/conversation-plan/domain.test.ts new file mode 100644 index 00000000..69c7ab7e --- /dev/null +++ b/apps/server/src/conversation-plan/domain.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { alice, bob, opened, option, source } from "./domain.test-fixtures"; + +describe("conversation plan events", () => { + test("relabeling preserves quoted evidence and replays with the new display label", () => { + let state = applyInference(initialState(), opened(), alice); + state = applyInference(state, option(state), bob); + state = applyEvent(state, { + id: "linked", + type: "card.linked", + threadId: "t1", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + questionnaireId: "card-1", + }); + let before = structuredClone(state.threads[0]!.contributions[0]!); + let event: ConversationPlan.Event = { + id: "relabel-1", + type: "option.relabeled", + threadId: "t1", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 4, + optionId: "o1", + label: "Outline when useful", + observedCardRevision: 0, + }; + let changed = applyEvent(state, event); + expect(changed.threads[0]!.contributions[0]).toMatchObject({ + ...before, + displayLabel: "Outline when useful", + }); + expect(state.threads[0]!.contributions[0]).toEqual(before); + expect(restoreState(changed, [alice, bob])).toEqual(changed); + expect(replay(changed.events).threads).toEqual(changed.threads); + expect(applyEvent(changed, event)).toBe(changed); + expect(() => applyEvent(state, { ...event, label: "A or B" })).toThrow(); + }); + test("a Planner-scribed question opens without a fabricated chat source and replays", () => { + let event: ConversationPlan.Event = { + id: "planner-question", + type: "thread.opened", + threadId: "planner-thread", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which approach should we take?", + }; + let state = applyEvent(initialState(), event); + expect(state.threads[0]).toMatchObject({ + questionSources: [], + questionAuthoring: "scribe", + }); + expect(restoreState(state, [])).toEqual(state); + expect(replay(state.events).threads).toEqual(state.threads); + expect(() => + applyEvent(initialState(), { + ...event, + origin: "classifier", + actor: { kind: "classifier" }, + }) + ).toThrow("opening requires a question source"); + }); + + test("accepted events replay to the same threads and revision", () => { + let state = applyInference(initialState(), opened(), alice); + state = applyInference(state, option(state), bob); + let restored = restoreState(structuredClone(state)); + let rebuilt = replay(state.events); + expect(restored).toEqual(state); + expect(rebuilt.threads).toEqual(state.threads); + expect(rebuilt.revision).toBe(state.revision); + }); + + test("rejects forged quote offsets and source authors", () => { + let forged = opened(); + forged.source!.quote = "a different question"; + expect(() => applyInference(initialState(), forged, alice)).toThrow(); + forged = opened(); + forged.source!.author = { kind: "member", handle: "mallory" }; + expect(() => applyInference(initialState(), forged, alice)).toThrow(); + }); + + test("deduplicates accepted IDs before checking a stale version", () => { + let first = opened(); + let state = applyInference(initialState(), first, alice); + expect(applyInference(state, first, alice)).toEqual(state); + let stale = option(state); + stale.observedThreadVersion = 0; + expect(() => applyInference(state, stale, bob)).toThrow(/stale/i); + }); + + test("accepted event data cannot be changed by mutating the candidate later", () => { + let candidate = opened(); + let state = applyInference(initialState(), candidate, alice); + candidate.source!.quote = "forged later"; + let accepted = state.events[0]; + expect("source" in accepted && accepted.source?.quote).toBe("Should we start with an outline?"); + expect(state.threads[0].questionSources[0].quote).toBe("Should we start with an outline?"); + }); + + test("shows the latest participant stance and retains the reversal", () => { + let state = applyInference(initialState(), opened(), alice); + state = applyInference(state, option(state), bob); + let first: ConversationPlan.Event = { + id: "m3:0:stance.changed:v1", + type: "stance.changed", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source({ ...alice, id: "m3", text: "I support this" }, "support"), + optionId: "o1", + position: "support", + }; + let supportMessage = { ...alice, id: "m3", text: "I support this" }; + state = applyInference(state, first, supportMessage); + let reversalMessage = { ...alice, id: "m4", text: "Actually I oppose this" }; + let reversal: ConversationPlan.Event = { + ...first, + id: "m4:0:stance.changed:v1", + observedThreadVersion: state.threads[0].version, + at: 4, + source: source(reversalMessage, "objection"), + position: "oppose", + }; + state = applyInference(state, reversal, reversalMessage); + expect(state.threads[0].stances).toMatchObject([ + { participant: "alice", optionId: "o1", position: "oppose" }, + ]); + expect(state.threads[0].stanceHistory.map((stance) => stance.position)).toEqual([ + "support", + "oppose", + ]); + }); + + test("preserves a decision after an explicit human reopening", () => { + let state = applyInference(initialState(), opened(), alice); + let decision: ConversationPlan.Event = { + id: "m3:0:decision.recorded:v1", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: 1, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 3, + text: "Use an outline", + explicit: true, + }; + state = applyEvent(state, decision); + expect(state.threads[0].status).toBe("decided"); + let reopen: ConversationPlan.Event = { + id: "m4:0:decision.reopened:v1", + type: "decision.reopened", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: { kind: "member", handle: "bob" }, + at: 4, + explicit: true, + }; + state = applyEvent(state, reopen); + expect(state.threads[0].status).toBe("reopened"); + expect(state.threads[0].decision).toBeUndefined(); + expect(state.threads[0].decisionHistory).toHaveLength(1); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("an agent cannot decide or cast a participant stance", () => { + let state = applyInference(initialState(), opened(), alice); + let agent: Chat.Entry = { + id: "agent1", + author: { kind: "agent" }, + text: "We decided to use an outline", + ts: 4, + }; + let decision: ConversationPlan.Event = { + id: "agent1:0:decision.recorded:v1", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: 1, + origin: "planner", + actor: { kind: "agent" }, + at: 4, + source: source(agent, "resolution"), + text: "Use an outline", + explicit: true, + }; + expect(() => applyInference(state, decision, agent)).toThrow( + "decisions are recorded on the card", + ); + let stance: ConversationPlan.Event = { + id: "agent1:0:stance.changed:v1", + type: "stance.changed", + threadId: "t1", + observedThreadVersion: 1, + origin: "planner", + actor: { kind: "agent" }, + at: 4, + source: source(agent, "support"), + position: "support", + }; + expect(() => applyInference(state, stance, agent)).toThrow(/human|member/i); + }); +}); diff --git a/apps/server/src/conversation-plan/domain.ts b/apps/server/src/conversation-plan/domain.ts new file mode 100644 index 00000000..5619a230 --- /dev/null +++ b/apps/server/src/conversation-plan/domain.ts @@ -0,0 +1,6 @@ +export { applyInference, completeAnalysis, enqueue, replay, retryMessage } from "./domain-analysis"; +export { applyCorrection } from "./domain-corrections"; +export { initialState } from "./domain-initial"; +export { actOnResearchOffer, offerResearch } from "./domain-research"; +export { validateStateSources } from "./state-provenance"; +export { restoreState } from "./state-restoration"; diff --git a/apps/server/src/conversation-plan/effect-fields.ts b/apps/server/src/conversation-plan/effect-fields.ts new file mode 100644 index 00000000..bd939f22 --- /dev/null +++ b/apps/server/src/conversation-plan/effect-fields.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +export const MAX_EFFECTS = 1024; + +export function invalid(): never { + throw new Error("hosted channel has invalid conversation effects"); +} + +export function object( + value: unknown, + keys: string[], + optional: string[] = [], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(); + let record = value as Record; + if ( + Object.keys(record).some(key => !keys.includes(key) && !optional.includes(key)) + || keys.some(key => !Object.hasOwn(record, key)) + ) return invalid(); + return record; +} + +export function bounded(value: unknown, max: number): value is string { + return typeof value === "string" && !!value.trim() && value.length <= max; +} + +export function scopedKey(proposalId: string, triggerEventId: string): string { + let raw = triggerEventId === proposalId + ? `scoped-choice:${proposalId}:proposal` + : `scoped-choice:${proposalId}:agreement:${triggerEventId}`; + return raw.length <= 300 + ? raw + : `scoped-choice:${createHash("sha256").update(raw).digest("hex")}`; +} diff --git a/apps/server/src/conversation-plan/effect-outbox.ts b/apps/server/src/conversation-plan/effect-outbox.ts new file mode 100644 index 00000000..22cf9772 --- /dev/null +++ b/apps/server/src/conversation-plan/effect-outbox.ts @@ -0,0 +1,164 @@ +import { ULID } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import { assertSourceShape } from "./sources"; +import type { Effect } from "./effect-types"; +import { bounded, invalid, MAX_EFFECTS, object, scopedKey } from "./effect-fields"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +export function restoreEffectOutbox(pendingValue: unknown, receiptValue: unknown): { + pending: Effect[]; + receipts: string[]; +} { + let pending = pendingValue === undefined ? [] : pendingValue; + let receipts = receiptValue === undefined ? [] : receiptValue; + if ( + !Array.isArray(pending) || pending.length > MAX_EFFECTS + || !Array.isArray(receipts) || receipts.length > MAX_EFFECTS + ) return invalid(); + let keys = new Set(); + for (let raw of pending) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return invalid(); + let base = raw as Record; + if (!bounded(base.key, 300) || keys.has(base.key)) return invalid(); + keys.add(base.key); + let item: Record; + if (base.kind === "insert-card") { + item = object(raw, ["key", "kind", "threadId", "header", "question", "options", "trigger"]); + if ( + item.key !== `insert:${item.threadId}` || !bounded(item.threadId, 200) + || !bounded(item.header, Question.limits.MAX_HEADER) + || !bounded(item.question, 500) || !bounded(item.trigger, 200) + || !Array.isArray(item.options) + || item.options.length > Question.limits.MAX_DECISION_OPTIONS + ) return invalid(); + let ids = new Set(); + for (let rawOption of item.options) { + let option = object(rawOption, ["id", "label"], ["source"]); + if ( + typeof option.id !== "string" || !ULID.test(option.id) + || !bounded(option.label, Question.limits.MAX_LABEL) + || ids.has(option.id as string) + ) return invalid(); + if (option.source !== undefined) { + try { + assertSourceShape(option.source); + if (option.source.role !== "option") return invalid(); + } catch { + return invalid(); + } + } + ids.add(option.id as string); + } + } else if (base.kind === "add-option") { + item = object(raw, ["key", "kind", "threadId", "optionId", "label", "trigger"], [ + "source", + ]); + if ( + item.key !== `option:${item.optionId}` || !bounded(item.threadId, 200) + || typeof item.optionId !== "string" || !ULID.test(item.optionId) + || !bounded(item.label, Question.limits.MAX_LABEL) + || !bounded(item.trigger, 200) + ) return invalid(); + if (item.source !== undefined) { + try { + assertSourceShape(item.source); + if (item.source.role !== "option") return invalid(); + } catch { + return invalid(); + } + } + } else if (base.kind === "suggest") { + item = object(raw, ["key", "kind", "threadId", "messageIds"], ["optionId"]); + if ( + !String(item.key).startsWith("suggest:") || !bounded(item.threadId, 200) + || item.optionId !== undefined + && (typeof item.optionId !== "string" || !ULID.test(item.optionId)) + || !Array.isArray(item.messageIds) || item.messageIds.length > 8 + || item.messageIds.some(id => !bounded(id, 200)) + || item.optionId === undefined && item.messageIds.length !== 0 + ) return invalid(); + } else if (base.kind === "prompt") { + item = object(raw, ["key", "kind", "threadId", "messageId", "generation"], [ + "optionId", + "sourceMessageIds", + ]); + if ( + !String(item.key).startsWith("prompt:") || String(item.key).length <= 7 + || !bounded(item.threadId, 200) + || item.optionId !== undefined + && (typeof item.optionId !== "string" || !ULID.test(item.optionId)) + || !bounded(item.messageId, 200) + || !Number.isSafeInteger(item.generation) || (item.generation as number) < 0 + || item.sourceMessageIds !== undefined && ( + !Array.isArray(item.sourceMessageIds) || item.sourceMessageIds.length > 8 + || item.sourceMessageIds.some(id => !bounded(id, 200)) + || new Set(item.sourceMessageIds).size !== item.sourceMessageIds.length + ) + ) return invalid(); + } else if (base.kind === "defer-prompt") { + item = object(raw, ["key", "kind", "threadId", "proposalId", "deferredEventId"]); + if ( + !bounded(item.threadId, 200) || !bounded(item.proposalId, 200) + || !bounded(item.deferredEventId, 200) + || item.key !== `defer-prompt:${item.deferredEventId}` + ) return invalid(); + } else if (base.kind === "scoped-choice") { + item = object(raw, [ + "key", + "kind", + "threadId", + "proposalId", + "cardId", + "optionId", + "label", + "scope", + "generation", + "triggerEventId", + "sources", + ]); + if ( + !bounded(item.threadId, 200) || !bounded(item.proposalId, 200) + || !bounded(item.cardId, 200) || !ULID.test(item.optionId as string) + || !bounded(item.label, Question.limits.MAX_LABEL) || item.scope !== "spike" + || !Number.isSafeInteger(item.generation) || (item.generation as number) < 0 + || !bounded(item.triggerEventId, 200) + || item.key !== scopedKey(item.proposalId, item.triggerEventId) + || !Array.isArray(item.sources) + || item.sources.length !== (item.triggerEventId === item.proposalId ? 1 : 2) + ) return invalid(); + try { + for (let [index, source] of (item.sources as unknown[]).entries()) { + assertSourceShape(source); + if ( + source.author.kind !== "member" + || index === 0 && source.role !== "support" + || index === 1 && !["support", "objection", "withdrawal"].includes(source.role) + ) return invalid(); + } + } catch { + return invalid(); + } + } else if (base.kind === "research") { + item = object(raw, ["key", "kind", "offerId"]); + if (!bounded(item.offerId, 200) || item.key !== `research:${item.offerId}`) { + return invalid(); + } + } else if (base.kind === "job") { + item = object(raw, ["key", "kind", "intent"], ["threadId"]); + let intent = object(item.intent, ["kind", "target", "trigger"]); + if ( + !String(item.key).startsWith("job:") + || item.threadId !== undefined && !bounded(item.threadId, 200) + || intent.kind === "prose" && !bounded(item.threadId, 200) + || !["heading", "refine", "suggest", "prose"].includes(intent.kind as string) + || !bounded(intent.target, 200) || !bounded(intent.trigger, 200) + ) return invalid(); + } else return invalid(); + } + let receiptKeys = new Set(); + for (let key of receipts) { + if (!bounded(key, 300) || receiptKeys.has(key) || keys.has(key)) return invalid(); + receiptKeys.add(key); + } + return { pending: pending as Effect[], receipts: receipts as string[] }; +} diff --git a/apps/server/src/conversation-plan/effect-projection.ts b/apps/server/src/conversation-plan/effect-projection.ts new file mode 100644 index 00000000..baf2908d --- /dev/null +++ b/apps/server/src/conversation-plan/effect-projection.ts @@ -0,0 +1,261 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { ULID } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import { currentScopedProposal, targetsScopedProposal } from "./events"; +import { QUESTION_SET_VERSION } from "./question-shared"; +import { effectivePreference } from "./preference"; +import { scopedKey } from "./effect-fields"; +import type { Effect } from "./effect-types"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +function purposeful(analysis?: ConversationPlan.AnalysisRecord): boolean { + if ( + analysis?.questionSetVersion !== QUESTION_SET_VERSION + && analysis?.questionSetVersion !== "conversation-plan-4" + ) return false; + let triage = analysis.passes.find(pass => pass.stage === "triage"); + let answer = triage?.answers.enough_purpose; + return answer?.type === "noul" && answer.noul >= 0.75; +} + +export function effectsFor( + events: readonly ConversationPlan.Event[], + state: ConversationPlan.State, + analysis?: ConversationPlan.AnalysisRecord, + records?: ReadonlyMap, +): Effect[] { + let effects: Effect[] = []; + let opened = new Set( + events.filter(event => event.type === "thread.opened").map(event => event.threadId), + ); + for (let event of events) { + let thread = state.threads.find(item => item.id === event.threadId); + if (!thread) continue; + if (event.type === "settle.deferred") { + effects.push({ + key: `defer-prompt:${event.id}`, + kind: "defer-prompt", + threadId: thread.id, + proposalId: event.proposalId, + deferredEventId: event.id, + }); + } + if ( + event.type === "stance.changed" && event.position !== "support" + && event.source.author.kind === "member" + ) { + let participant = event.source.author.handle; + let eventIndex = state.events.findIndex(item => item.id === event.id); + let earlier = eventIndex < 0 ? [] : state.events.slice(0, eventIndex); + let proposal = earlier.findLast((item): item is Extract< + ConversationPlan.Event, + { type: "scoped-choice.proposed" } + > => + item.type === "scoped-choice.proposed" && item.threadId === thread.id + && (typeof event.scopedProposalId !== "string" + || item.id === event.scopedProposalId) + ); + if ( + !proposal || proposal.source.author.kind !== "member" + || !targetsScopedProposal(event, proposal.id, proposal.optionId) + || thread.questionnaireId !== proposal.cardId + || thread.pendingScopedChoice + && thread.pendingScopedChoice.proposalId !== proposal.id + || earlier.some(item => + item.type === "scoped-choice.saved" && item.proposalId === proposal.id + ) + || participant !== proposal.source.author.handle + && !earlier.some(item => + item.type === "scoped-choice.agreed" && item.proposalId === proposal.id + && item.source.author.kind === "member" + && item.source.author.handle === participant + ) + ) continue; + let record = records?.get(proposal.cardId); + if (records && !record) throw new Error("linked scoped choice card is missing"); + effects.push({ + key: scopedKey(proposal.id, event.id), + kind: "scoped-choice", + threadId: thread.id, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + generation: record?.history.length ?? 0, + triggerEventId: event.id, + sources: [proposal.source, event.source], + }); + continue; + } + if (event.type === "scoped-choice.proposed" || event.type === "scoped-choice.agreed") { + let proposal = currentScopedProposal(thread, state.events); + if ( + !proposal || !thread.questionnaireId || thread.questionnaireId !== proposal.cardId + || ["decided", "discarded"].includes(thread.status) + || state.events.some(item => + item.type === "scoped-choice.saved" && item.proposalId === proposal.id + ) + || event.type === "scoped-choice.proposed" && event.id !== proposal.id + || event.type === "scoped-choice.agreed" && event.proposalId !== proposal.id + || event.type === "scoped-choice.agreed" && ( + event.cardId !== proposal.cardId || event.optionId !== proposal.optionId + || event.label !== proposal.label || event.scope !== proposal.scope + ) + ) continue; + let record = records?.get(proposal.cardId); + if (records && !record) throw new Error("linked scoped choice card is missing"); + effects.push({ + key: scopedKey(proposal.id, event.id), + kind: "scoped-choice", + threadId: thread.id, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + generation: record?.history.length ?? 0, + triggerEventId: event.id, + sources: [ + proposal.source, + ...(event.type === "scoped-choice.agreed" + ? [event.source] + : []), + ], + }); + continue; + } + if (event.type === "thread.opened") { + if (thread.questionnaireId || ["decided", "discarded"].includes(thread.status)) continue; + effects.push({ + key: `insert:${thread.id}`, + kind: "insert-card", + threadId: thread.id, + header: thread.question.trim().slice(0, Question.limits.MAX_HEADER), + question: thread.question, + options: thread.contributions.filter(item => item.kind === "option" && ULID.test(item.id)) + .slice(0, Question.limits.MAX_DECISION_OPTIONS) + .map(item => { + let source = item.sources.find(source => source.role === "option"); + return { + id: item.id, + label: item.text.trim().slice(0, Question.limits.MAX_LABEL), + ...(source ? { source } : {}), + }; + }), + trigger: event.id, + }); + } else if ( + event.type === "option.added" + && (event.origin === "classifier" || !!event.source) + ) { + if (opened.has(thread.id) || !ULID.test(event.contribution.id)) continue; + let card = thread.questionnaireId ? records?.get(thread.questionnaireId) : undefined; + if (card?.definition?.questions[0]?.options.some(item => item.id === event.contribution.id)) { + continue; + } + effects.push({ + key: `option:${event.contribution.id}`, + kind: "add-option", + threadId: thread.id, + optionId: event.contribution.id, + label: event.contribution.text.trim().slice(0, Question.limits.MAX_LABEL), + trigger: event.id, + ...(event.source?.role === "option" ? { source: event.source } : {}), + }); + if (thread.questionnaireId && event.origin !== "human") { + if (!event.source) continue; + effects.push({ + key: `job:suggest:${thread.questionnaireId}:${event.id}`, + kind: "job", + threadId: thread.id, + intent: { + kind: "suggest", + target: thread.questionnaireId, + trigger: event.source.messageId, + }, + }); + } + } else if (event.type === "card.linked") { + let source = thread.questionSources[0]; + if (!source) continue; + effects.push({ + key: `job:refine:${event.questionnaireId}`, + kind: "job", + threadId: thread.id, + intent: { kind: "refine", target: event.questionnaireId, trigger: source.messageId }, + }); + } else if (event.type === "thread.leaning") { + if (!event.optionId || !ULID.test(event.optionId)) continue; + effects.push({ + key: `suggest:${event.id}`, + kind: "suggest", + threadId: thread.id, + optionId: event.optionId, + messageIds: "source" in event && event.source ? [event.source.messageId] : [], + }); + } + } + let reconciliations = new Map>(); + for (let event of events) { + if ( + event.type === "settle.suggested" || event.type === "settle.agreed" + || event.type === "settle.resumed" + ) { + reconciliations.set(event.threadId, event); + } else if (event.type === "stance.changed" && event.optionId) { + let thread = state.threads.find(item => item.id === event.threadId); + let actor = event.source.author; + let eventIndex = state.events.findIndex(item => item.id === event.id); + if (thread?.pendingSettle?.optionId !== event.optionId || actor.kind !== "member") continue; + if ( + state.events.slice(0, eventIndex < 0 ? undefined : eventIndex).some(item => + item.threadId === event.threadId + && (item.type === "settle.suggested" || item.type === "settle.agreed") + && item.optionId === event.optionId + && item.source.author.kind === "member" + && item.source.author.handle === actor.handle + ) + ) reconciliations.set(event.threadId, event); + } + } + for (let [threadId, trigger] of reconciliations) { + let thread = state.threads.find(item => item.id === threadId); + if ( + !thread?.pendingSettle || !ULID.test(thread.pendingSettle.optionId) + || ["decided", "discarded"].includes(thread.status) + ) continue; + let preference = effectivePreference(thread, state.events); + let optionId = preference?.optionId; + let messageIds = preference?.messageIds ?? []; + let record = thread.questionnaireId && records?.get(thread.questionnaireId); + if (thread.questionnaireId && !record) throw new Error("linked prompt card is missing"); + effects.push({ + key: `suggest:${trigger.id}`, + kind: "suggest", + threadId, + ...(optionId ? { optionId } : {}), + messageIds, + }); + effects.push({ + key: `prompt:${trigger.id}`, + kind: "prompt", + threadId, + ...(optionId ? { optionId } : {}), + messageId: trigger.source.messageId, + generation: record ? record.history.length : 0, + sourceMessageIds: messageIds, + }); + } + if (purposeful(analysis)) { + effects.push({ + key: "job:heading:document", + kind: "job", + intent: { kind: "heading", target: "document", trigger: analysis!.messageId }, + }); + } + return effects; +} diff --git a/apps/server/src/conversation-plan/effect-runner.ts b/apps/server/src/conversation-plan/effect-runner.ts new file mode 100644 index 00000000..fea56b50 --- /dev/null +++ b/apps/server/src/conversation-plan/effect-runner.ts @@ -0,0 +1,110 @@ +import type { Effect, EffectDeps } from "./effect-types"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +export async function runEffects(deps: EffectDeps, effects: readonly Effect[]): Promise { + let completed = 0; + let blocked = new Set(); + for ( + let effect of [ + ...effects.filter(item => item.kind !== "job"), + ...effects.filter(item => item.kind === "job"), + ] + ) { + // A fresh, explicit writer action supplies the owner context for research. + if (effect.kind === "research") continue; + if (deps.applied(effect.key)) continue; + if ( + effect.kind !== "insert-card" && "threadId" in effect && effect.threadId + && blocked.has(effect.threadId) + ) continue; + try { + if (effect.kind === "insert-card") { + let target = deps.target(effect.threadId); + if (target.kind !== "unlinked") { + await deps.markApplied(effect.key); + completed++; + continue; + } + let card = await deps.insertCard({ + threadId: effect.threadId, + header: effect.header, + question: effect.question, + options: effect.options, + }); + await deps.link(effect.threadId, card); + } else if (effect.kind === "job") { + if (effect.intent.kind === "prose" && !effect.threadId) { + throw new Error("prose job effect has no linked thread"); + } + if (effect.threadId) { + let target = deps.target(effect.threadId); + if (target.kind === "unlinked") { + blocked.add(effect.threadId); + continue; + } + if ( + effect.intent.kind === "prose" + && !deps.proseReady?.( + effect.threadId, + effect.intent.target, + effect.intent.trigger, + ) + ) { + await deps.markApplied(effect.key); + completed++; + continue; + } + if ( + target.kind === "closed" + && effect.intent.kind !== "prose" + ) { + await deps.markApplied(effect.key); + completed++; + continue; + } + } + if (!deps.enqueueJob) continue; + await deps.enqueueJob(effect.intent, effect.key); + } else if (effect.kind === "scoped-choice") { + if (!deps.scopedChoice) continue; + await deps.scopedChoice(effect); + } else if (effect.kind === "defer-prompt") { + if (!deps.deferPrompt) continue; + await deps.deferPrompt(effect); + } else { + let target = deps.target(effect.threadId); + if (target.kind === "unlinked") { + blocked.add(effect.threadId); + continue; + } + if (target.kind === "open") { + if (effect.kind === "add-option") { + await deps.addOption(target.id, { + optionId: effect.optionId, + label: effect.label, + trigger: effect.trigger, + ...(effect.source ? { source: effect.source } : {}), + }); + } else if (effect.kind === "prompt") { + await deps.prompt(target.id, effect.generation, { + optionId: effect.optionId, + messageId: effect.messageId, + sourceMessageIds: effect.sourceMessageIds, + }); + } else { + await deps.suggest(target.id, { + optionId: effect.optionId, + messageIds: effect.messageIds, + }); + } + } + } + await deps.markApplied(effect.key); + completed++; + } catch (error) { + if ("threadId" in effect && effect.threadId) blocked.add(effect.threadId); + deps.report(error); + } + } + return completed; +} diff --git a/apps/server/src/conversation-plan/effect-types.ts b/apps/server/src/conversation-plan/effect-types.ts new file mode 100644 index 00000000..780c1753 --- /dev/null +++ b/apps/server/src/conversation-plan/effect-types.ts @@ -0,0 +1,104 @@ +import type { ConversationPlan } from "@chopin/protocol"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +export type JobIntent = { + kind: "heading" | "refine" | "suggest" | "prose"; + target: string; + trigger: string; +}; + +export type Effect = + | { + key: string; + kind: "insert-card"; + threadId: string; + header: string; + question: string; + options: Array<{ id: string; label: string; source?: ConversationPlan.SourceRef }>; + trigger: string; + } + | { + key: string; + kind: "add-option"; + threadId: string; + optionId: string; + label: string; + trigger: string; + source?: ConversationPlan.SourceRef; + } + | { + key: string; + kind: "suggest"; + threadId: string; + optionId?: string; + messageIds: string[]; + } + | { + key: string; + kind: "prompt"; + threadId: string; + optionId?: string; + messageId: string; + generation: number; + /** Absent on legacy outbox entries; present for source-aware prompt deduplication. */ + sourceMessageIds?: string[]; + } + | { + key: string; + kind: "defer-prompt"; + threadId: string; + proposalId: string; + deferredEventId: string; + } + | { + key: string; + kind: "scoped-choice"; + threadId: string; + proposalId: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + generation: number; + triggerEventId: string; + sources: ConversationPlan.SourceRef[]; + } + | { key: string; kind: "research"; offerId: string } + | { key: string; kind: "job"; intent: JobIntent; threadId?: string }; + +export type CardTarget = { kind: "unlinked" | "closed" } | { kind: "open"; id: string }; + +export type EffectDeps = { + applied(key: string): boolean; + markApplied(key: string): Promise; + target(threadId: string): CardTarget; + /** A decided prose job may pass a closed card only for its saved generation. */ + proseReady?(threadId: string, questionnaireId: string, trigger: string): boolean; + insertCard(input: { + threadId: string; + header: string; + question: string; + options: Array<{ id: string; label: string; source?: ConversationPlan.SourceRef }>; + }): Promise; + link(threadId: string, questionnaireId: string): Promise; + addOption(questionnaireId: string, input: { + optionId: string; + label: string; + trigger?: string; + source?: ConversationPlan.SourceRef; + }): Promise; + suggest( + questionnaireId: string, + input: { optionId?: string; messageIds: string[] }, + ): Promise; + prompt( + questionnaireId: string, + generation: number, + source?: { optionId?: string; messageId: string; sourceMessageIds?: string[] }, + ): Promise; + deferPrompt?(effect: Extract): Promise; + scopedChoice?(effect: Extract): Promise; + /** Omitted until the narrow Planner job queue arrives in Slice 5. */ + enqueueJob?(intent: JobIntent, effectKey: string): Promise; + report(error: unknown): void; +}; diff --git a/apps/server/src/conversation-plan/effects-interpretation.test.ts b/apps/server/src/conversation-plan/effects-interpretation.test.ts new file mode 100644 index 00000000..90c05753 --- /dev/null +++ b/apps/server/src/conversation-plan/effects-interpretation.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { applyInference, initialState } from "./domain"; +import { effectsFor } from "./effects"; +import { interpretMessage } from "./interpret"; +import { message, mockResult, seeded } from "./interpret.test-fixtures"; +import { planEvents } from "./policy"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; + +// Whole pipeline callbacks3532/3554/4479 from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2. + +test("unlinked messages retain purpose evidence for later effects", async () => { + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: message("purpose", "We're making a shared planning document."), + recent: [], + state: seeded(), + ask: async (request) => { + calls++; + return mockResult(request.questions, { enough_purpose: 0.95 }); + }, + }); + expect(calls).toBe(1); + expect(output.events).toEqual([]); + expect(output.analysis.status).toBe("unlinked"); + expect(output.analysis.questionSetVersion).toBe("conversation-plan-8"); + expect(output.analysis.passes[0].answers.enough_purpose).toEqual({ + type: "noul", + noul: 0.95, + }); +}); + +test("an opening purpose queues a heading but no card even if Jev calls it a question", async () => { + let current = message("figma-purpose", "Okay we gotta figure out what we're doing for auth"); + let state = initialState(); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { enough_purpose: 0.98, new_question: 0.08, act: "question", significance: 2 } + : { c0_role: "question", c0_thread: "new" }, + ), + }); + expect(calls).toBe(2); + expect(output.analysis.passes[0]?.answers.act).toMatchObject({ choice: "question" }); + expect(output.events).toEqual([]); + let effects = effectsFor(output.events, state, { + ...output.analysis, + messageId: current.id, + eventIds: [], + }); + expect(effects).toMatchObject([{ + kind: "job", + intent: { kind: "heading", target: "document", trigger: current.id }, + }]); +}); + +test("D01's owned purpose and final question open one editor-context card", () => { + let input = d01RecordedOpening(); + expect(input.candidates.map(candidate => [candidate.quote, candidate.start, candidate.end])) + .toEqual([ + ["need to pick an editor before comments get any deeper.", 0, 54], + ["what are we comparing?", 55, 77], + ]); + let output = planEvents(input); + expect(output.events).toHaveLength(1); + expect(output.events[0]).toMatchObject({ + type: "thread.opened", + question: input.message.text, + source: { + messageId: input.message.id, + author: input.message.author, + quote: input.message.text, + start: 0, + end: input.message.text.length, + role: "question", + }, + }); + let accepted = applyInference(input.state, output.events[0]!, input.message); + expect(effectsFor(output.events, accepted)).toContainEqual(expect.objectContaining({ + kind: "insert-card", + header: input.message.text, + })); +}); diff --git a/apps/server/src/conversation-plan/effects-outbox.test.ts b/apps/server/src/conversation-plan/effects-outbox.test.ts new file mode 100644 index 00000000..4fe675a5 --- /dev/null +++ b/apps/server/src/conversation-plan/effects-outbox.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { type Effect, restoreEffectOutbox } from "./effects"; +import { LATER, OPTION } from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("restored pending effects and receipts are strictly bounded", () => { + let pending: Effect = { + key: `option:${LATER}`, + kind: "add-option", + threadId: "t1", + optionId: LATER, + label: "Auth0", + trigger: "add-1", + }; + expect(restoreEffectOutbox([pending], ["insert:t1"])).toEqual({ + pending: [pending], + receipts: ["insert:t1"], + }); + expect(restoreEffectOutbox(undefined, undefined)).toEqual({ pending: [], receipts: [] }); + expect(() => restoreEffectOutbox([{ ...pending, surprise: true }], [])) + .toThrow("conversation effect"); + expect(() => restoreEffectOutbox([{ ...pending, optionId: "old-id" }], [])) + .toThrow("conversation effect"); + expect(() => restoreEffectOutbox([{ ...pending, optionId: [LATER] }], [])) + .toThrow("conversation effect"); + expect(() => + restoreEffectOutbox([{ + key: "insert:t1", + kind: "insert-card", + threadId: "t1", + header: "Auth", + question: "Which auth system?", + options: [{ id: [LATER], label: "Auth0" }], + trigger: "open-1", + }], []) + ).toThrow("conversation effect"); + expect(() => + restoreEffectOutbox([{ + key: "suggest:settle-1", + kind: "suggest", + threadId: "t1", + optionId: [LATER], + messageIds: ["m1"], + }], []) + ).toThrow("conversation effect"); + expect(() => restoreEffectOutbox([pending, pending], [])) + .toThrow("conversation effect"); + expect(() => restoreEffectOutbox([pending], [pending.key])) + .toThrow("conversation effect"); + expect(() => restoreEffectOutbox([pending], Array.from({ length: 1025 }, (_, i) => `k${i}`))) + .toThrow("conversation effect"); +}); + +test("restored prompt effects require a bounded captured generation", () => { + let prompt: Effect = { + key: "prompt:agree-1", + kind: "prompt", + threadId: "t1", + optionId: OPTION, + messageId: "m3", + generation: 0, + }; + let refreshed = { ...prompt, sourceMessageIds: ["m6"] }; + expect(restoreEffectOutbox([refreshed], [])).toEqual({ pending: [refreshed], receipts: [] }); + let cleared = { ...prompt, optionId: undefined, sourceMessageIds: [] }; + delete cleared.optionId; + expect(restoreEffectOutbox([cleared], [])).toEqual({ pending: [cleared], receipts: [] }); + expect(() => restoreEffectOutbox([{ ...prompt, sourceMessageIds: ["m6", "m6"] }], [])) + .toThrow("conversation effect"); + expect(restoreEffectOutbox([prompt], []).pending).toEqual([prompt]); + for (let generation of [undefined, -1, 0.5, 2 ** 53]) { + expect(() => restoreEffectOutbox([{ ...prompt, generation }], [])) + .toThrow("conversation effect"); + } + expect(() => restoreEffectOutbox([{ ...prompt, optionId: "old" }], [])) + .toThrow("conversation effect"); +}); + +test("restored prose effects require their linked thread", () => { + expect(() => + restoreEffectOutbox([{ + key: "job:prose:Q:1", + kind: "job", + intent: { kind: "prose", target: "Q", trigger: "decided:Q:1" }, + }], []) + ).toThrow("conversation effect"); +}); diff --git a/apps/server/src/conversation-plan/effects-projection.test.ts b/apps/server/src/conversation-plan/effects-projection.test.ts new file mode 100644 index 00000000..21ff8222 --- /dev/null +++ b/apps/server/src/conversation-plan/effects-projection.test.ts @@ -0,0 +1,180 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Question from "@chopin/question"; +import { initialState } from "./domain"; +import { effectsFor, recoverable } from "./effects"; +import { event, LATER, OPTION, thread } from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("an opened thread inserts only card-valid quoted options; legacy recovery stays empty", () => { + let open = event("thread.opened", "open-1", { question: "Which auth system?" }); + let state = { ...initialState(), threads: [thread()] }; + expect(effectsFor([open], state)).toMatchObject([{ + key: "insert:t1", + kind: "insert-card", + options: [{ id: OPTION, label: "GitHub Apps" }], + }]); + let old = thread({ + contributions: [{ + ...thread().contributions[0], + id: "classifier:historical", + }], + }); + let legacy = { ...initialState(), events: [open], threads: [old] }; + expect(recoverable(legacy)).toEqual([open]); + expect(effectsFor(recoverable(legacy), legacy)[0]).toMatchObject({ options: [] }); +}); + +test("card labels use the same bound on initial and later quoted options", () => { + let quote = "A".repeat(Question.limits.MAX_LABEL + 30); + let initial = thread({ contributions: [{ ...thread().contributions[0], text: quote }] }); + let state = { ...initialState(), threads: [initial] }; + let opened = event("thread.opened", "open-long"); + let added = event("option.added", "add-long", { + contribution: { id: LATER, text: quote, authoring: "quoted" }, + }); + expect(effectsFor([opened], state)[0]).toMatchObject({ + options: [{ id: OPTION, label: "A".repeat(Question.limits.MAX_LABEL) }], + }); + expect(effectsFor([added], state)[0]).toMatchObject({ + label: "A".repeat(Question.limits.MAX_LABEL), + }); + let spaced = ` ${" ".repeat(Question.limits.MAX_LABEL)}GitHub Apps `; + let spacedThread = thread({ + question: `${" ".repeat(Question.limits.MAX_HEADER)}Which auth system?`, + contributions: [{ ...thread().contributions[0], text: spaced }], + }); + let spacedState = { ...initialState(), threads: [spacedThread] }; + expect(effectsFor([opened], spacedState)[0]).toMatchObject({ + header: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let spacedAdded = event("option.added", "add-spaced", { + contribution: { id: LATER, text: spaced, authoring: "quoted" }, + }); + expect(effectsFor([spacedAdded], spacedState)[0]).toMatchObject({ label: "GitHub Apps" }); +}); + +test("a pre-link option waits; link makes a separate refine job", () => { + let source: ConversationPlan.SourceRef = { + messageId: "option-message", + author: { kind: "member", handle: "jules" }, + quote: "Auth0", + start: 0, + end: 5, + role: "option", + }; + let option = event("option.added", "option-2", { + source, + contribution: { id: LATER, text: "Auth0", authoring: "quoted" }, + }); + let unlinked = { ...initialState(), threads: [thread()] }; + expect(effectsFor([option], unlinked).map(item => item.kind)).toEqual(["add-option"]); + let linked = { + ...initialState(), + threads: [thread({ + questionnaireId: "Q", + questionSources: [{ ...source, role: "question", messageId: "question-message" }], + })], + }; + expect(effectsFor([option], linked).map(item => item.kind)).toEqual([ + "add-option", + "job", + ]); + expect(effectsFor([event("card.linked", "link-1", { questionnaireId: "Q" })], linked)) + .toMatchObject([{ key: "job:refine:Q", kind: "job", intent: { kind: "refine" } }]); +}); + +test("sourced Planner chat options reach linked cards; source-free card options do not", () => { + let linked = { ...initialState(), threads: [thread({ questionnaireId: "Q" })] }; + let quoted = event("option.added", "planner-quoted", { + origin: "planner", + actor: { kind: "agent" }, + source: { + messageId: "agent-message", + author: { kind: "agent" }, + quote: "Auth0", + start: 0, + end: 5, + role: "option", + }, + contribution: { id: LATER, text: "Auth0", authoring: "quoted" }, + }); + let cardTool = event("option.added", "planner-card", { + origin: "planner", + actor: { kind: "agent" }, + contribution: { id: LATER, text: "Auth0", authoring: "scribe" }, + }); + expect(effectsFor([quoted], linked).map(item => item.kind)).toEqual(["add-option", "job"]); + expect(effectsFor([cardTool], linked)).toEqual([]); +}); + +test("new card jobs retain their event receipt but trigger from the source chat message", () => { + let source = { + messageId: "original-question", + author: { kind: "member" as const, handle: "jules" }, + quote: "Which auth system?", + start: 0, + end: 18, + role: "question" as const, + }; + let linked = { + ...initialState(), + threads: [thread({ questionnaireId: "Q", questionSources: [source] })], + }; + expect(effectsFor([event("card.linked", "link-event", { questionnaireId: "Q" })], linked)) + .toMatchObject([{ + key: "job:refine:Q", + intent: { kind: "refine", trigger: "original-question" }, + }]); + let quoted = event("option.added", "option-event", { + source: { ...source, messageId: "option-message", role: "option" }, + contribution: { id: LATER, text: "Auth0", authoring: "quoted" }, + }); + expect(effectsFor([quoted], linked)[1]).toMatchObject({ + key: "job:suggest:Q:option-event", + intent: { kind: "suggest", trigger: "option-message" }, + }); +}); + +test("unlinked purpose creates one heading intent without a thread event", () => { + let analysis = { + messageId: "m1", + questionSetVersion: "conversation-plan-8", + modelVersion: "jev", + status: "unlinked", + eventIds: [], + passes: [{ stage: "triage", answers: { enough_purpose: { type: "noul", noul: 0.75 } } }], + } as ConversationPlan.AnalysisRecord; + expect(effectsFor([], initialState(), analysis)).toMatchObject([{ + key: "job:heading:document", + kind: "job", + intent: { kind: "heading", target: "document", trigger: "m1" }, + }]); + expect(effectsFor([], initialState(), { + ...analysis, + questionSetVersion: "conversation-plan-4", + })).toMatchObject([{ key: "job:heading:document" }]); + expect( + effectsFor([], initialState(), { ...analysis, questionSetVersion: "conversation-plan-3" }), + ) + .toEqual([]); + expect(effectsFor([], initialState(), { + ...analysis, + passes: [{ stage: "triage", answers: { enough_purpose: { type: "noul", noul: 0.749 } } }], + })).toEqual([]); +}); + +test("new settle and leaning events do not target historical non-ULID options", () => { + let oldId = "classifier:historical-option"; + let state = { + ...initialState(), + threads: [thread({ + contributions: [{ ...thread().contributions[0], id: oldId }], + })], + }; + expect(effectsFor([event("settle.suggested", "settle-1", { optionId: oldId })], state)) + .toEqual([]); + expect(effectsFor([event("thread.leaning", "lean-1", { optionId: oldId })], state)) + .toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/effects-retry.test.ts b/apps/server/src/conversation-plan/effects-retry.test.ts new file mode 100644 index 00000000..0918b03e --- /dev/null +++ b/apps/server/src/conversation-plan/effects-retry.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import { type Effect, runEffects } from "./effects"; +import { LATER, OPTION, sink } from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("a failed link retries the same card, then delivers options before refine", async () => { + let linked = false; + let failLink = true; + let failOption = true; + let state = sink({ + target: () => linked ? { kind: "open", id: "Q" } : { kind: "unlinked" }, + insertCard: async () => (state.calls.push("insert:Q"), "Q"), + link: async () => { + if (failLink) { + failLink = false; + throw new Error("link commit failed"); + } + linked = true; + state.calls.push("link:Q"); + }, + addOption: async () => { + if (failOption) { + failOption = false; + throw new Error("option commit failed"); + } + state.calls.push("option"); + }, + enqueueJob: async () => void state.calls.push("refine"), + }); + let effects: Effect[] = [ + { + key: "insert:t1", + kind: "insert-card", + threadId: "t1", + header: "Auth", + question: "Which auth system?", + options: [], + trigger: "open-1", + }, + { + key: "option:later", + kind: "add-option", + threadId: "t1", + optionId: LATER, + label: "Auth0", + trigger: "add-1", + }, + { + key: "job:refine:Q", + kind: "job", + threadId: "t1", + intent: { kind: "refine", target: "Q", trigger: "link-1" }, + }, + ]; + expect(await runEffects(state.deps, effects)).toBe(0); + expect(state.calls).toEqual(["insert:Q"]); + expect(await runEffects(state.deps, effects)).toBe(1); + expect(state.calls).toEqual(["insert:Q", "insert:Q", "link:Q"]); + expect(state.calls).not.toContain("refine"); + expect(await runEffects(state.deps, effects)).toBe(2); + expect(state.calls).toEqual(["insert:Q", "insert:Q", "link:Q", "option", "refine"]); +}); + +test("all quoted options project before a job, forwarding its raw intent and receipt key", async () => { + let state = sink({ + addOption: async (_id, input) => void state.calls.push(input.label), + enqueueJob: async (intent, key) => void state.calls.push(`${key}:${intent.trigger}`), + }); + let effects: Effect[] = [ + { + key: "option:first", + kind: "add-option", + threadId: "t1", + optionId: OPTION, + label: "First", + trigger: "event-first", + }, + { + key: "job:suggest:Q:event-first", + kind: "job", + threadId: "t1", + intent: { kind: "suggest", target: "Q", trigger: "event-first" }, + }, + { + key: "option:second", + kind: "add-option", + threadId: "t1", + optionId: LATER, + label: "Second", + trigger: "event-second", + }, + ]; + expect(await runEffects(state.deps, effects)).toBe(3); + expect(state.calls).toEqual([ + "First", + "Second", + "job:suggest:Q:event-first:event-first", + ]); +}); diff --git a/apps/server/src/conversation-plan/effects-runner.test.ts b/apps/server/src/conversation-plan/effects-runner.test.ts new file mode 100644 index 00000000..0d1b1b27 --- /dev/null +++ b/apps/server/src/conversation-plan/effects-runner.test.ts @@ -0,0 +1,215 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { type Effect, effectsFor, runEffects } from "./effects"; +import { event, LATER, OPTION, sink, thread } from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("mirroring an existing linked card option skips add and job but keeps the prompt", () => { + let source = { + messageId: "m5", + author: { kind: "member" as const, handle: "jules" }, + quote: "Let's just go with Auth0.", + start: 0, + end: 24, + role: "option" as const, + }; + let linked = { + ...initialState(), + threads: [thread({ + questionnaireId: "Q", + pendingSettle: { optionId: LATER, proposer: "jules", messageId: "m5" }, + contributions: [{ + id: LATER, + kind: "option", + text: "Auth0", + authoring: "scribe", + sources: [source], + actor: { kind: "classifier" }, + }], + })], + }; + let added = event("option.added", "card-mirror", { + source, + contribution: { id: LATER, text: "Auth0", authoring: "scribe" }, + }); + let settle = event("settle.suggested", "card-settle", { + source: { ...source, role: "resolution" }, + optionId: LATER, + }); + let records = new Map([["Q", { + history: [], + definition: { questions: [{ options: [{ id: LATER }] }] }, + }]]); + let effects = effectsFor([added, settle], linked, undefined, records); + expect(effects.some(item => item.kind === "add-option" || item.kind === "job")).toBe(false); + expect(effects.some(item => item.kind === "prompt")).toBe(true); +}); + +test("unlinked work defers, closed work is receipted, and absent job sink never spins", async () => { + let state = sink({ target: () => ({ kind: "unlinked" }) }); + let option: Effect = { + key: "option:2", + kind: "add-option", + threadId: "t1", + optionId: LATER, + label: "Auth0", + trigger: "option-2", + }; + let job: Effect = { + key: "job:heading:document", + kind: "job", + intent: { kind: "heading", target: "document", trigger: "m1" }, + }; + expect(await runEffects(state.deps, [option, job])).toBe(0); + expect(state.calls).toEqual([]); + expect([...state.receipts]).toEqual([]); + state.deps.target = () => ({ kind: "closed" }); + expect(await runEffects(state.deps, [option, job])).toBe(1); + expect([...state.receipts]).toEqual(["option:2"]); + state.deps.enqueueJob = async () => void state.calls.push("job"); + expect(await runEffects(state.deps, [option, job])).toBe(1); + expect(state.calls).toEqual(["job"]); +}); + +test("a closed card accepts only its current saved prose generation", async () => { + let calls: string[] = []; + let state = sink({ + target: () => ({ kind: "closed" }), + proseReady: (thread, id, trigger) => thread === "t1" && id === "Q" && trigger === "decided:Q:2", + enqueueJob: async intent => void calls.push(intent.trigger), + }); + let stale: Effect = { + key: "job:prose:Q:1", + kind: "job", + threadId: "t1", + intent: { kind: "prose", target: "Q", trigger: "decided:Q:1" }, + }; + let current: Effect = { + key: "job:prose:Q:2", + kind: "job", + threadId: "t1", + intent: { kind: "prose", target: "Q", trigger: "decided:Q:2" }, + }; + let mismatched: Effect = { + ...current, + key: "job:prose:Q:2:wrong-thread", + threadId: "t2", + }; + expect(await runEffects(state.deps, [stale, mismatched, current])).toBe(3); + expect(calls).toEqual(["decided:Q:2"]); + expect(state.receipts).toEqual(new Set([stale.key, mismatched.key, current.key])); + let reopened = sink({ + target: () => ({ kind: "open", id: "Q" }), + proseReady: () => false, + enqueueJob: async intent => void calls.push(intent.trigger), + }); + expect(await runEffects(reopened.deps, [current])).toBe(1); + expect(reopened.receipts).toEqual(new Set([current.key])); + expect(calls).toEqual(["decided:Q:2"]); + let malformedErrors: unknown[] = []; + let malformed = sink({ + enqueueJob: async intent => void calls.push(intent.trigger), + report: error => void malformedErrors.push(error), + }); + expect(await runEffects(malformed.deps, [{ ...current, threadId: undefined }])).toBe(0); + expect(malformed.calls).toEqual([]); + expect(malformedErrors).toHaveLength(1); +}); + +test("a pending suggestion waits for a link and targets the linked card exactly once", async () => { + let linked = false; + let delivered: Array<{ card: string; optionId?: string; messageIds: string[] }> = []; + let state = sink({ + target: () => linked ? { kind: "open", id: "Q" } : { kind: "unlinked" }, + suggest: async (card, input) => void delivered.push({ card, ...input }), + }); + let suggestion: Effect = { + key: "suggest:settle-1", + kind: "suggest", + threadId: "t1", + optionId: OPTION, + messageIds: ["m1"], + }; + expect(await runEffects(state.deps, [suggestion])).toBe(0); + expect(delivered).toEqual([]); + linked = true; + expect(await runEffects(state.deps, [suggestion])).toBe(1); + expect(delivered).toEqual([{ card: "Q", optionId: OPTION, messageIds: ["m1"] }]); + expect(await runEffects(state.deps, [suggestion])).toBe(0); + expect(delivered).toHaveLength(1); +}); + +test("failed insert retains its key; retry links and receipts it", async () => { + let attempts = 0; + let state = sink({ + target: () => ({ kind: "unlinked" }), + insertCard: async () => { + if (attempts++ === 0) throw new Error("storage unavailable"); + state.calls.push("insert"); + return "Q"; + }, + }); + let insert: Effect = { + key: "insert:t1", + kind: "insert-card", + threadId: "t1", + header: "Auth", + question: "Which auth system?", + options: [], + trigger: "open-1", + }; + expect(await runEffects(state.deps, [insert])).toBe(0); + expect([...state.receipts]).toEqual([]); + expect(await runEffects(state.deps, [insert])).toBe(1); + expect(state.calls).toEqual(["insert", "link"]); + expect([...state.receipts]).toEqual(["insert:t1"]); +}); + +test("closed inserts are terminal without creating a card", async () => { + let state = sink({ target: () => ({ kind: "closed" }) }); + let insert: Effect = { + key: "insert:t1", + kind: "insert-card", + threadId: "t1", + header: "Auth", + question: "Which auth system?", + options: [], + trigger: "open-1", + }; + expect(await runEffects(state.deps, [insert])).toBe(1); + expect(state.calls).toEqual([]); + expect([...state.receipts]).toEqual(["insert:t1"]); +}); + +test("a lost insert receipt recognizes the already-linked card on retry", async () => { + let linked = false; + let failReceipt = true; + let state = sink({ + target: () => linked ? { kind: "open", id: "Q" } : { kind: "unlinked" }, + link: async () => { + linked = true; + state.calls.push("link"); + }, + markApplied: async key => { + if (failReceipt) { + failReceipt = false; + throw new Error("receipt commit failed"); + } + state.receipts.add(key); + }, + }); + let insert: Effect = { + key: "insert:t1", + kind: "insert-card", + threadId: "t1", + header: "Auth", + question: "Which auth system?", + options: [], + trigger: "open-1", + }; + expect(await runEffects(state.deps, [insert])).toBe(0); + expect(state.calls).toEqual(["insert", "link"]); + expect(await runEffects(state.deps, [insert])).toBe(1); + expect(state.calls).toEqual(["insert", "link"]); + expect([...state.receipts]).toEqual(["insert:t1"]); +}); diff --git a/apps/server/src/conversation-plan/effects-scoped.test.ts b/apps/server/src/conversation-plan/effects-scoped.test.ts new file mode 100644 index 00000000..b1d77b67 --- /dev/null +++ b/apps/server/src/conversation-plan/effects-scoped.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { effectsFor } from "./effects"; +import { + m7MessageText, + scopedAgreement, + scopedProposal, + scopedState, +} from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("a scoped choice proposal creates a keyed inline Save notice without a final prompt", () => { + let proposal = scopedProposal(); + let effects = effectsFor([proposal], scopedState(proposal)); + expect(effects).toHaveLength(1); + expect(effects[0]).toMatchObject({ + key: `scoped-choice:${proposal.id}:proposal`, + kind: "scoped-choice", + threadId: proposal.threadId, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + sources: [proposal.source], + }); + expect(effects.some(item => item.kind === "prompt")).toBe(false); +}); + +test("a scoped agreement refreshes the same proposal notice with both exact member sources", () => { + let proposal = scopedProposal(); + let agreement = scopedAgreement(proposal); + expect(agreement.source.quote).toBe( + m7MessageText.slice(agreement.source.start, agreement.source.end), + ); + let effects = effectsFor([agreement], scopedState(proposal, agreement)); + expect(effects).toHaveLength(1); + expect(effects[0]).toMatchObject({ + key: `scoped-choice:${proposal.id}:agreement:${agreement.id}`, + kind: "scoped-choice", + threadId: proposal.threadId, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + sources: [proposal.source, agreement.source], + }); + expect(effects.some(item => item.kind === "prompt")).toBe(false); +}); + +test("saving a scoped choice does not create another active Save notice", () => { + let proposal = scopedProposal(); + let saved: ConversationPlan.Event = { + id: "saved-m6", + type: "scoped-choice.saved", + threadId: proposal.threadId, + observedThreadVersion: 2, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 8, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + sources: [proposal.source], + expectedGeneration: 0, + }; + let state = scopedState(proposal, saved); + state.threads[0]!.pendingScopedChoice = undefined; + let effects = effectsFor([saved], state); + expect(effects.map(item => String(item.kind))).not.toContain("scoped-choice"); + expect(effects.some(item => item.kind === "prompt")).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/effects-settlement.test.ts b/apps/server/src/conversation-plan/effects-settlement.test.ts new file mode 100644 index 00000000..e475f16f --- /dev/null +++ b/apps/server/src/conversation-plan/effects-settlement.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { type Effect, effectsFor, runEffects } from "./effects"; +import { event, OPTION, sink, thread } from "./effects.test-fixtures"; + +// Whole callbacks from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts. +test("one sourced settle suggestion selects the card and queues a Save prompt", () => { + let suggestion = event("settle.suggested", "settle-1", { + optionId: OPTION, + source: { + messageId: "m2", + author: { kind: "member", handle: "mina" }, + quote: "Lets do GitHub Apps", + start: 0, + end: 18, + role: "resolution", + }, + }); + let linked = { + ...initialState(), + events: [suggestion], + threads: [thread({ + questionnaireId: "Q", + pendingSettle: { optionId: OPTION, proposer: "mina", messageId: "m2" }, + })], + }; + expect(effectsFor( + [suggestion], + linked, + undefined, + new Map([["Q", { history: [] }]]), + )).toEqual([{ + key: "suggest:settle-1", + kind: "suggest", + threadId: "t1", + optionId: OPTION, + messageIds: ["m2"], + }, { + key: "prompt:settle-1", + kind: "prompt", + threadId: "t1", + optionId: OPTION, + messageId: "m2", + generation: 0, + sourceMessageIds: ["m2"], + }]); +}); + +test("a sourced agreement queues a prompt bound to the card's current generation", () => { + let agreement = event("settle.agreed", "agree-1", { + optionId: OPTION, + source: { + messageId: "m3", + author: { kind: "member", handle: "jules" }, + quote: "Sounds good.", + start: 0, + end: 12, + role: "support", + }, + }); + let proposal = event("settle.suggested", "settle-1", { + optionId: OPTION, + source: { + messageId: "m2", + author: { kind: "member", handle: "mina" }, + quote: "Lets do GitHub Apps", + start: 0, + end: 18, + role: "resolution", + }, + }); + let linked = { + ...initialState(), + events: [proposal, agreement], + threads: [thread({ + questionnaireId: "Q", + pendingSettle: { optionId: OPTION, proposer: "mina", messageId: "m2" }, + })], + }; + expect(effectsFor( + [agreement], + linked, + undefined, + new Map([ + ["Q", { history: [{ at: 1 }, { at: 1 }] }], + ]), + )).toEqual([{ + key: "suggest:agree-1", + kind: "suggest", + threadId: "t1", + optionId: OPTION, + messageIds: ["m2", "m3"], + }, { + key: "prompt:agree-1", + kind: "prompt", + threadId: "t1", + optionId: OPTION, + messageId: "m3", + generation: 2, + sourceMessageIds: ["m2", "m3"], + }]); + let unlinked = { + ...linked, + threads: [thread({ pendingSettle: linked.threads[0].pendingSettle })], + }; + expect(effectsFor([agreement], unlinked, undefined, new Map())).toMatchObject([{}, { + kind: "prompt", + generation: 0, + }]); + expect(() => effectsFor([agreement], linked, undefined, new Map())) + .toThrow("linked prompt card is missing"); +}); + +test("a prompt defers until linked, receipts a closed card, and passes its captured generation", async () => { + let linked = false; + let delivered: Array<{ card: string; generation: number }> = []; + let state = sink({ + target: () => linked ? { kind: "open", id: "Q" } : { kind: "unlinked" }, + prompt: async (card, generation) => void delivered.push({ card, generation }), + }); + let prompt: Effect = { + key: "prompt:agree-1", + kind: "prompt", + threadId: "t1", + optionId: OPTION, + messageId: "m3", + generation: 0, + }; + expect(await runEffects(state.deps, [prompt])).toBe(0); + linked = true; + expect(await runEffects(state.deps, [prompt])).toBe(1); + expect(delivered).toEqual([{ card: "Q", generation: 0 }]); + expect([...state.receipts]).toEqual(["prompt:agree-1"]); + let closed = sink({ target: () => ({ kind: "closed" }) }); + expect(await runEffects(closed.deps, [prompt])).toBe(1); + expect(closed.calls).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/effects.test-fixtures.ts b/apps/server/src/conversation-plan/effects.test-fixtures.ts new file mode 100644 index 00000000..12005981 --- /dev/null +++ b/apps/server/src/conversation-plan/effects.test-fixtures.ts @@ -0,0 +1,136 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import type { EffectDeps } from "./effects"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.test.ts helpers/data. + +export let OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export let LATER = "01K0N4W3B7P27CBAEC7A8C8WEB"; + +export let m7MessageText = "yep, Lexical for the spike. not a final library call yet."; + +export function thread(overrides: Partial = {}): ConversationPlan.Thread { + return { + id: "t1", + question: "Which auth system?", + questionSources: [], + questionAuthoring: "quoted", + status: "exploring", + contributions: [{ + id: OPTION, + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 1, + ...overrides, + }; +} + +export function event(type: string, id: string, extra: Record = {}) { + return { + id, + type, + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + ...extra, + } as ConversationPlan.Event; +} + +export function sink(overrides: Partial = {}) { + let receipts = new Set(); + let calls: string[] = []; + let deps: EffectDeps = { + applied: key => receipts.has(key), + markApplied: async key => void receipts.add(key), + target: () => ({ kind: "open", id: "Q" }), + insertCard: async () => (calls.push("insert"), "Q"), + link: async () => void calls.push("link"), + addOption: async () => void calls.push("option"), + suggest: async () => void calls.push("suggest"), + prompt: async () => void calls.push("prompt"), + report: () => {}, + ...overrides, + }; + return { deps, receipts, calls }; +} + +export type ScopedProposal = Extract; + +export type ScopedAgreement = Extract; + +export let scopedProposal = (): ScopedProposal => ({ + id: "proposal-m6", + type: "scoped-choice.proposed", + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: { + 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", + }, + cardId: "Q", + optionId: OPTION, + label: "Lexical", + scope: "spike", +}); + +export let scopedAgreement = (proposal: ScopedProposal): ScopedAgreement => ({ + id: "agreement-m7", + type: "scoped-choice.agreed", + threadId: proposal.threadId, + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 7, + source: { + messageId: "m7", + author: { kind: "member", handle: "rob" }, + quote: m7MessageText.slice(0, 27), + start: 0, + end: 27, + role: "support", + }, + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, +}); + +export let scopedState = ( + proposal: ScopedProposal, + ...later: ConversationPlan.Event[] +): ConversationPlan.State => ({ + ...initialState(), + events: [proposal, ...later], + threads: [thread({ + questionnaireId: "Q", + contributions: [{ ...thread().contributions[0]!, text: "Lexical" }], + pendingScopedChoice: { + proposalId: proposal.id, + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + proposer: "mei", + messageId: proposal.source.messageId, + }, + })], +}); diff --git a/apps/server/src/conversation-plan/effects.ts b/apps/server/src/conversation-plan/effects.ts new file mode 100644 index 00000000..6f6f1cfd --- /dev/null +++ b/apps/server/src/conversation-plan/effects.ts @@ -0,0 +1,16 @@ +import type { ConversationPlan } from "@chopin/protocol"; +export { MAX_EFFECTS } from "./effect-fields"; +export { restoreEffectOutbox } from "./effect-outbox"; +export { effectsFor } from "./effect-projection"; +export { runEffects } from "./effect-runner"; +export type { CardTarget, Effect, EffectDeps, JobIntent } from "./effect-types"; +// Extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, effects.ts. + +export function recoverable(state: ConversationPlan.State): ConversationPlan.Event[] { + let open = new Set( + state.threads.filter(thread => + !thread.questionnaireId && ["exploring", "leaning", "reopened"].includes(thread.status) + ).map(thread => thread.id), + ); + return state.events.filter(event => event.type === "thread.opened" && open.has(event.threadId)); +} diff --git a/apps/server/src/conversation-plan/event-contributions.ts b/apps/server/src/conversation-plan/event-contributions.ts new file mode 100644 index 00000000..c50e3393 --- /dev/null +++ b/apps/server/src/conversation-plan/event-contributions.ts @@ -0,0 +1,120 @@ +import { MAX_CONTRIBUTIONS } from "./validation-fields"; +import type { ConversationPlan } from "@chopin/protocol"; +import { activeScopedSupport, currentScopedProposal, targetsScopedProposal } from "./event-support"; + +type Event = Extract< + ConversationPlan.Event, + { + type: + | "option.added" + | "reason.added" + | "constraint.added" + | "stance.changed" + | "option.relabeled"; + } +>; + +export function applyContributionEvent( + next: ConversationPlan.State, + thread: ConversationPlan.Thread, + event: Event, +): void { + switch (event.type) { + case "option.added": + case "reason.added": + case "constraint.added": { + let kind = event.type.split(".")[0] as ConversationPlan.Contribution["kind"]; + if (event.source && event.source.role !== kind) { + throw new Error("contribution source role disagrees"); + } + if (event.origin !== "human" && event.contribution.authoring === "human-edited") { + throw new Error("inference cannot claim human wording"); + } + if ( + event.source && event.contribution.authoring === "quoted" + && event.contribution.text !== event.source.quote + ) throw new Error("quoted wording must match source"); + if ( + next.threads.some((item) => + item.contributions.some((value) => value.id === event.contribution.id) + ) + ) throw new Error("duplicate contribution ID"); + if (thread.contributions.length >= MAX_CONTRIBUTIONS) { + throw new Error("conversation thread contribution limit reached"); + } + if ( + event.contribution.targetId && event.contribution.targetId !== thread.id + && !thread.contributions.some((value) => value.id === event.contribution.targetId) + ) throw new Error("unknown contribution target"); + thread.contributions.push({ + ...event.contribution, + kind, + sources: event.source ? [event.source] : [], + actor: event.actor, + }); + break; + } + case "stance.changed": { + if (event.source.author.kind !== "member") { + throw new Error("human member required for stance"); + } + if ( + event.optionId + && !thread.contributions.some((item) => + item.id === event.optionId && item.kind === "option" + ) + ) throw new Error("unknown stance option"); + if ( + (event.position === "support" && event.source.role !== "support") + || (event.position === "oppose" && event.source.role !== "objection") + || (event.position === "neutral" && event.source.role !== "withdrawal") + ) throw new Error("stance source role disagrees"); + let stance: ConversationPlan.Stance = { + id: event.id, + participant: event.source.author.handle, + optionId: event.optionId, + position: event.position, + sources: [event.source], + at: event.at, + }; + thread.stanceHistory.push(stance); + thread.stances = thread.stances.filter((item) => + item.participant !== stance.participant || item.optionId !== stance.optionId + ); + thread.stances.push(stance); + if (thread.pendingScopedChoice) { + let proposal = currentScopedProposal(thread, next.events); + if ( + proposal && targetsScopedProposal(event, proposal.id, proposal.optionId) + && activeScopedSupport([...next.events, event], proposal).length === 0 + ) thread.pendingScopedChoice = undefined; + } + break; + } + case "option.relabeled": { + if (!thread.questionnaireId || thread.status === "decided" || thread.status === "discarded") { + throw new Error("option's decision is not open and linked"); + } + let option = thread.contributions.find(item => + item.kind === "option" && item.id === event.optionId + ); + if (!option) throw new Error("unknown relabel option"); + if ( + option.displayLabel === event.label || !option.displayLabel && option.text === event.label + ) { + throw new Error("option label is unchanged"); + } + let folded = event.label.toLocaleLowerCase(); + if ( + thread.contributions.some(item => + item.kind === "option" && item.id !== option.id + && (item.displayLabel ?? item.text).trim().toLocaleLowerCase() === folded + ) + ) { + throw new Error("duplicate option label"); + } + option.displayLabel = event.label; + break; + } + } +} diff --git a/apps/server/src/conversation-plan/event-corrections.ts b/apps/server/src/conversation-plan/event-corrections.ts new file mode 100644 index 00000000..7bd8b982 --- /dev/null +++ b/apps/server/src/conversation-plan/event-corrections.ts @@ -0,0 +1,111 @@ +import { MAX_CONTRIBUTIONS } from "./validation-fields"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +type Event = Extract; +type Member = Extract; + +export function applyCorrectionEvent( + next: ConversationPlan.State, + thread: ConversationPlan.Thread, + event: Event, +): void { + switch (event.type) { + case "card.corrected": { + let change = event.change; + if (change.kind === "edit" && change.field === "question") { + thread.question = change.text; + thread.questionAuthoring = "human-edited"; + thread.questionEditedBy = (event.actor as Member).handle; + } else if (change.kind === "edit" && change.field === "contribution") { + let contribution = thread.contributions.find((item) => item.id === change.contributionId); + if (!contribution) throw new Error("unknown contribution"); + contribution.text = change.text; + contribution.authoring = "human-edited"; + contribution.editedBy = (event.actor as Member).handle; + } else if (change.kind === "edit" && change.field === "decision") { + if (!thread.decision) throw new Error("no current decision"); + thread.decision.text = change.text; + thread.decision.editedBy = (event.actor as Member).handle; + let historical = thread.decisionHistory.find((item) => item.id === thread.decision?.id); + if (historical) { + historical.text = change.text; + historical.editedBy = (event.actor as Member).handle; + } + } else if (change.kind === "set-status") { + if (change.status === "reopened" && (thread.status !== "decided" || !thread.decision)) { + throw new Error("only a decided thread can reopen"); + } + thread.status = change.status; + thread.decision = undefined; + } else if (change.kind === "move") { + let target = next.threads.find((item) => item.id === change.targetThreadId); + if (!target || target.id === thread.id || target.version !== change.targetVersion) { + throw new Error("stale target thread version"); + } + if (target.contributions.length >= MAX_CONTRIBUTIONS) { + throw new Error("conversation thread contribution limit reached"); + } + let index = thread.contributions.findIndex((item) => item.id === change.contributionId); + if (index < 0) throw new Error("unknown contribution"); + let contribution = thread.contributions[index]; + if ( + contribution.kind === "option" + && (thread.stanceHistory.some((item) => item.optionId === contribution.id) + || thread.contributions.some((item) => item.targetId === contribution.id) + || thread.decisionHistory.some((item) => item.optionId === contribution.id)) + ) throw new Error("referenced option cannot move alone"); + thread.contributions.splice(index, 1); + contribution.targetId = target.id; + target.contributions.push(contribution); + target.version++; + } else if (change.kind === "retarget-stance" || change.kind === "dismiss-stance") { + let index = thread.stances.findIndex((item) => item.id === change.stanceId); + if (index < 0) throw new Error("stance is not current"); + let stance = thread.stances[index]; + if (change.kind === "retarget-stance") { + if ( + change.optionId !== undefined + && !thread.contributions.some((item) => + item.id === change.optionId && item.kind === "option" + ) + ) throw new Error("unknown stance option"); + if (stance.optionId === change.optionId) throw new Error("stance target is unchanged"); + if ( + thread.stances.some((item) => + item.id !== stance.id && item.participant === stance.participant + && item.optionId === change.optionId + ) + ) throw new Error("participant already has a current stance on target"); + let corrected: ConversationPlan.Stance = { + ...stance, + id: event.id, + optionId: change.optionId, + corrects: stance.id, + correctedBy: (event.actor as Member).handle, + }; + thread.stances.splice(index, 1, corrected); + thread.stanceHistory.push(corrected); + } else { + thread.stances.splice(index, 1); + } + } else if (change.kind === "retarget-contribution") { + let contribution = thread.contributions.find((item) => item.id === change.contributionId); + if (!contribution || contribution.kind === "option") { + throw new Error("target correction requires a reason or constraint"); + } + if ( + change.targetId !== thread.id + && !thread.contributions.some((item) => + item.id === change.targetId && item.kind === "option" + ) + ) throw new Error("unknown contribution target"); + if (contribution.targetId === change.targetId) { + throw new Error("contribution target is unchanged"); + } + contribution.targetId = change.targetId; + contribution.targetEditedBy = (event.actor as Member).handle; + } + break; + } + } +} diff --git a/apps/server/src/conversation-plan/event-lifecycle.ts b/apps/server/src/conversation-plan/event-lifecycle.ts new file mode 100644 index 00000000..19571045 --- /dev/null +++ b/apps/server/src/conversation-plan/event-lifecycle.ts @@ -0,0 +1,129 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +type Event = Extract< + ConversationPlan.Event, + { + type: + | "thread.leaning" + | "decision.recorded" + | "decision.reopened" + | "candidate.proposed" + | "candidate.confirmed" + | "candidate.rejected" + | "card.linked" + | "thread.discarded"; + } +>; +type Member = Extract; + +export function applyLifecycleEvent(thread: ConversationPlan.Thread, event: Event): void { + switch (event.type) { + case "thread.leaning": { + if (thread.status === "decided" || thread.status === "discarded") { + throw new Error("cannot infer leaning over a closed thread"); + } + let supporters = new Set( + thread.stances.filter((item) => + item.position === "support" && item.optionId === event.optionId + ).map((item) => item.participant), + ); + if (supporters.size < 2) throw new Error("leaning needs several participant stances"); + thread.status = "leaning"; + break; + } + case "decision.recorded": { + if (thread.status === "discarded") throw new Error("thread is discarded"); + if (thread.status === "decided") throw new Error("decision must be reopened first"); + if ( + event.origin !== "human" + && (event.source?.author.kind !== "member" || event.source.role !== "resolution") + ) throw new Error("decision requires explicit human resolution"); + if ( + event.optionId + && !thread.contributions.some((item) => + item.id === event.optionId && item.kind === "option" + ) + ) throw new Error("unknown decision option"); + if ( + event.origin !== "human" + && thread.stances.some((item) => + item.position === "oppose" && (!item.optionId || item.optionId === event.optionId) + ) + ) throw new Error("contradictory stance needs human resolution"); + let decision: ConversationPlan.Decision = { + id: event.id, + text: event.text, + optionId: event.optionId, + sources: event.source ? [event.source] : [], + actor: event.origin === "human" ? event.actor as Member : event.source!.author as Member, + at: event.at, + }; + thread.decision = decision; + thread.decisionHistory.push(decision); + thread.status = "decided"; + thread.pendingSettle = undefined; + thread.pendingScopedChoice = undefined; + break; + } + case "decision.reopened": + if (thread.status !== "decided") throw new Error("only a decided thread can reopen"); + if ( + event.origin !== "human" + && (event.source?.author.kind !== "member" || event.source.role !== "reopening") + ) throw new Error("reopening requires explicit human statement"); + thread.decision = undefined; + thread.status = "reopened"; + thread.pendingSettle = undefined; + break; + case "candidate.proposed": + if (event.source.role !== event.candidate.kind) { + throw new Error("candidate source role disagrees"); + } + if (thread.candidates.some((candidate) => candidate.id === event.candidate.id)) { + throw new Error("duplicate candidate ID"); + } + if ((event.candidate.kind === "reopening") !== (thread.status === "decided")) { + throw new Error("candidate does not match thread status"); + } + thread.candidates.push({ ...event.candidate, sources: [event.source], status: "pending" }); + break; + case "candidate.confirmed": + case "candidate.rejected": { + let candidate = thread.candidates.find((item) => item.id === event.candidateId); + if (!candidate || candidate.status !== "pending") throw new Error("candidate is not pending"); + if ( + event.type === "candidate.confirmed" + && ((candidate.kind === "reopening") !== (thread.status === "decided")) + ) throw new Error("candidate no longer matches thread status"); + candidate.status = event.type === "candidate.confirmed" ? "confirmed" : "rejected"; + candidate.actedBy = (event.actor as Member).handle; + if (event.type === "candidate.confirmed") { + if (candidate.kind === "resolution") { + let decision: ConversationPlan.Decision = { + id: event.id, + text: candidate.text, + sources: candidate.sources, + actor: event.actor as Member, + at: event.at, + }; + thread.decision = decision; + thread.decisionHistory.push(decision); + thread.status = "decided"; + } else { + thread.decision = undefined; + thread.status = "reopened"; + } + } + break; + } + case "card.linked": + if (thread.questionnaireId) throw new Error("thread already has a card"); + thread.questionnaireId = event.questionnaireId; + break; + case "thread.discarded": + thread.status = "discarded"; + thread.pendingSettle = undefined; + thread.pendingScopedChoice = undefined; + break; + } +} diff --git a/apps/server/src/conversation-plan/event-settlement.ts b/apps/server/src/conversation-plan/event-settlement.ts new file mode 100644 index 00000000..375db462 --- /dev/null +++ b/apps/server/src/conversation-plan/event-settlement.ts @@ -0,0 +1,160 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { isDeepStrictEqual } from "node:util"; +import { activeSettleDeferral } from "./preference"; +import { activeScopedSupport, currentScopedProposal } from "./event-support"; + +type Event = Extract< + ConversationPlan.Event, + { + type: + | "settle.suggested" + | "settle.agreed" + | "settle.deferred" + | "settle.resumed" + | "scoped-choice.proposed" + | "scoped-choice.agreed" + | "scoped-choice.saved"; + } +>; + +export function applySettlementEvent( + next: ConversationPlan.State, + thread: ConversationPlan.Thread, + event: Event, +): void { + switch (event.type) { + case "settle.suggested": + if (event.source.role !== "resolution") throw new Error("settle source role disagrees"); + if (event.source.author.kind !== "member") throw new Error("human member required to settle"); + if (thread.status === "decided" || thread.status === "discarded") { + throw new Error("thread is not open for settling"); + } + if ( + !thread.contributions.some((item) => item.id === event.optionId && item.kind === "option") + ) { + throw new Error("unknown settle option"); + } + if (!activeSettleDeferral(thread, next.events)) { + thread.pendingSettle = { + optionId: event.optionId, + proposer: event.source.author.handle, + messageId: event.source.messageId, + }; + } + break; + case "settle.agreed": + if (event.source.role !== "support") throw new Error("agreement source role disagrees"); + if (!thread.pendingSettle) throw new Error("no pending proposal to settle"); + if (thread.pendingSettle.optionId !== event.optionId) { + throw new Error("agreement names another option"); + } + if ( + event.source.author.kind !== "member" + || event.source.author.handle === thread.pendingSettle.proposer + ) throw new Error("agreement needs another member"); + break; + case "settle.deferred": { + let pending = thread.pendingSettle; + let proposal = next.events.find(item => + item.id === event.proposalId && item.type === "settle.suggested" + && item.threadId === thread.id + ); + if ( + !pending || !proposal || proposal.type !== "settle.suggested" + || proposal.optionId !== pending.optionId + || proposal.source.messageId !== pending.messageId + || proposal.source.author.kind !== "member" + || proposal.source.author.handle !== pending.proposer + || event.source.author.kind !== "member" + || event.source.author.handle !== pending.proposer + || !next.events.some(item => + item.type === "stance.changed" && item.threadId === thread.id + && item.source.messageId === event.source.messageId + && item.source.author.kind === "member" + && item.source.author.handle === pending.proposer + && item.optionId === pending.optionId && item.position === "neutral" + ) + || activeSettleDeferral(thread, next.events) + ) throw new Error("deferral does not target the active proposal"); + break; + } + case "settle.resumed": { + let deferred = activeSettleDeferral(thread, next.events); + if ( + !deferred || deferred.id !== event.deferredEventId + || deferred.proposalId !== event.proposalId + || !thread.pendingSettle || event.source.author.kind !== "member" + ) throw new Error("verification does not target the active deferral"); + break; + } + case "scoped-choice.proposed": + if ( + thread.status === "decided" || thread.status === "discarded" + || thread.questionnaireId !== event.cardId + || event.source.author.kind !== "member" + ) throw new Error("scoped choice needs an open linked card"); + thread.pendingScopedChoice = { + proposalId: event.id, + cardId: event.cardId, + optionId: event.optionId, + label: event.label, + scope: "spike", + proposer: event.source.author.handle, + messageId: event.source.messageId, + }; + break; + case "scoped-choice.agreed": { + let pending = thread.pendingScopedChoice; + let proposal = currentScopedProposal(thread, next.events); + if ( + thread.status === "decided" || thread.status === "discarded" + || !pending || !proposal || proposal.id !== event.proposalId + || pending.proposalId !== undefined && pending.proposalId !== event.proposalId + || thread.questionnaireId !== event.cardId + || pending.cardId !== event.cardId || pending.optionId !== event.optionId + || pending.label !== event.label || pending.scope !== event.scope + || event.source.author.kind !== "member" + || event.source.author.handle === pending.proposer + ) throw new Error("scoped choice agreement does not match the current proposal"); + break; + } + case "scoped-choice.saved": { + let pending = thread.pendingScopedChoice; + let proposal = currentScopedProposal(thread, next.events); + let agreement = event.agreementId + ? next.events.find(item => item.id === event.agreementId) + : undefined; + let active = proposal ? activeScopedSupport(next.events, proposal) : []; + let lineageMatches = event.supportEventIds !== undefined + ? active.length > 0 + && isDeepStrictEqual(event.supportEventIds, active.map(item => item.id)) + && isDeepStrictEqual(event.sources, active.map(item => item.source)) + : active.length > 0 + && isDeepStrictEqual(active.map(item => item.id), [ + proposal?.id, + ...(agreement && agreement.type === "scoped-choice.agreed" + ? [agreement.id] + : []), + ]) + && isDeepStrictEqual(event.sources, active.map(item => item.source)); + if ( + thread.status === "decided" || thread.status === "discarded" + || !pending || !proposal || proposal.id !== event.proposalId + || thread.questionnaireId !== event.cardId + || pending.cardId !== event.cardId || pending.optionId !== event.optionId + || pending.label !== event.label || pending.scope !== event.scope + || next.events.some(item => + item.type === "scoped-choice.saved" && item.proposalId === event.proposalId + ) + || !lineageMatches + || event.agreementId !== undefined && ( + agreement?.type !== "scoped-choice.agreed" + || agreement.proposalId !== proposal.id || agreement.threadId !== thread.id + || agreement.cardId !== event.cardId || agreement.optionId !== event.optionId + || agreement.label !== event.label || agreement.scope !== event.scope + ) + ) throw new Error("saved scoped choice does not match the current proposal"); + break; + } + } +} diff --git a/apps/server/src/conversation-plan/event-support.test.ts b/apps/server/src/conversation-plan/event-support.test.ts new file mode 100644 index 00000000..3594dc43 --- /dev/null +++ b/apps/server/src/conversation-plan/event-support.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { + activeScopedSupport, + applyEvent, + currentScopedProposal, + targetsScopedProposal, +} from "./events"; + +type Event = ConversationPlan.Event; +function source( + role: ConversationPlan.SourceRole, + quote: string, + handle = "maggie", + messageId = "message-1", +): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle }, + quote, + start: 0, + end: quote.length, + role, + }; +} +function base(id: string, version = 0): ConversationPlan.EventBase { + return { + id, + threadId: "thread-1", + observedThreadVersion: version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + }; +} +function scoped() { + let state: ConversationPlan.State = { + schemaVersion: 1, + revision: 0, + events: [], + threads: [], + queue: [], + analysis: [], + }; + state = applyEvent(state, { + ...base("open"), + type: "thread.opened", + source: source("question", "Which?"), + question: "Which?", + }); + state = applyEvent(state, { ...base("link", 1), type: "card.linked", questionnaireId: "card-1" }); + let proposal: Extract = { + ...base("proposal", 2), + type: "scoped-choice.proposed", + source: source("support", "I'd pick Bun for the spike."), + cardId: "card-1", + optionId: "option-1", + label: "Bun", + scope: "spike", + }; + state = applyEvent(state, proposal); + return { state, proposal }; +} +function agreement( + version: number, + id = "agreement", +): Extract { + return { + ...base(id, version), + type: "scoped-choice.agreed", + source: source("support", "yep, Bun for the spike.", "alex", id), + proposalId: "proposal", + cardId: "card-1", + optionId: "option-1", + label: "Bun", + scope: "spike", + }; +} + +test("support helpers resolve the current proposal and latest member evidence in event order", () => { + let { state, proposal } = scoped(); + let first = agreement(3); + state = applyEvent(state, first); + let latest = agreement(4, "latest-agreement"); + state = applyEvent(state, latest); + expect(currentScopedProposal(state.threads[0]!, state.events)?.id).toBe(proposal.id); + expect(activeScopedSupport(state.events, proposal).map(event => event.id)).toEqual([ + "proposal", + "latest-agreement", + ]); + let legacy = structuredClone(state.threads[0]!); + delete legacy.pendingScopedChoice!.proposalId; + expect(currentScopedProposal(legacy, state.events)?.id).toBe(proposal.id); + expect(currentScopedProposal(legacy, [...state.events, { ...proposal, id: "ambiguous" }])) + .toBeUndefined(); +}); + +test("withdrawal targets retain legacy absence while explicit null does not retract scoped support", () => { + let { state, proposal } = scoped(); + let withdrawal: Extract = { + ...base("withdraw", 3), + type: "stance.changed", + source: source("withdrawal", "I withdraw"), + position: "neutral", + }; + expect(targetsScopedProposal(withdrawal, proposal.id, proposal.optionId)).toBe(true); + expect( + targetsScopedProposal( + { ...withdrawal, scopedProposalId: null }, + proposal.id, + proposal.optionId, + ), + ).toBe(false); + expect( + targetsScopedProposal( + { ...withdrawal, scopedProposalId: "other" }, + proposal.id, + proposal.optionId, + ), + ).toBe(false); + expect( + activeScopedSupport([...state.events, { ...withdrawal, scopedProposalId: null }], proposal).map( + event => event.id, + ), + ).toEqual(["proposal"]); + state = applyEvent(state, withdrawal); + expect(activeScopedSupport(state.events, proposal)).toEqual([]); + expect(state.threads[0]!.pendingScopedChoice).toBeUndefined(); +}); + +test("scoped saves bind exact ordered active sources and preserve the provisional thread", () => { + let { state, proposal } = scoped(); + let agreed = agreement(3); + state = applyEvent(state, agreed); + let save: Extract = { + ...base("save", 4), + origin: "human", + actor: { kind: "member", handle: "maggie" }, + type: "scoped-choice.saved", + proposalId: proposal.id, + supportEventIds: [proposal.id, agreed.id], + cardId: "card-1", + optionId: "option-1", + label: "Bun", + scope: "spike", + sources: [proposal.source, agreed.source], + expectedGeneration: 0, + }; + expect(() => applyEvent(state, { ...save, supportEventIds: [agreed.id, proposal.id] })).toThrow( + "saved scoped choice does not match the current proposal", + ); + let saved = applyEvent(state, save); + expect(saved.threads[0]!.status).toBe("exploring"); + expect(saved.threads[0]!.decision).toBeUndefined(); + expect(saved.threads[0]!.pendingScopedChoice?.proposalId).toBe(proposal.id); + expect(() => applyEvent(saved, { ...save, id: "save-again", observedThreadVersion: 5 })).toThrow( + "saved scoped choice does not match the current proposal", + ); +}); diff --git a/apps/server/src/conversation-plan/event-support.ts b/apps/server/src/conversation-plan/event-support.ts new file mode 100644 index 00000000..0c7814d8 --- /dev/null +++ b/apps/server/src/conversation-plan/event-support.ts @@ -0,0 +1,69 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +type Event = ConversationPlan.Event; + +export function currentScopedProposal( + thread: ConversationPlan.Thread, + events: readonly Event[], +): Extract | undefined { + let pending = thread.pendingScopedChoice; + if (!pending) return; + let matches = events.filter(( + event, + ): event is Extract => + event.type === "scoped-choice.proposed" && event.threadId === thread.id + && event.cardId === pending.cardId && event.optionId === pending.optionId + && event.label === pending.label && event.scope === pending.scope + && event.source.messageId === pending.messageId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + ); + return pending.proposalId + ? matches.find(event => event.id === pending.proposalId) + : matches.length === 1 + ? matches[0] + : undefined; +} + +/** An absent link preserves previously persisted optionless stance behavior. */ +export function targetsScopedProposal( + event: Extract, + proposalId: string | undefined, + optionId: string, +): boolean { + if (event.position === "support" || event.scopedProposalId === null) return false; + if (event.optionId !== undefined && event.optionId !== optionId) return false; + return event.scopedProposalId === undefined || event.scopedProposalId === proposalId; +} + +type ScopedSupportEvent = Extract; + +/** Accepted support in event order, with each member's latest active source. */ +export function activeScopedSupport( + events: readonly Event[], + proposal: Extract, +): ScopedSupportEvent[] { + let start = events.findIndex(item => item.id === proposal.id); + if (start < 0 || proposal.source.author.kind !== "member") return []; + let active = new Map(); + for (let item of events.slice(start)) { + if (item.id === proposal.id) { + active.set(proposal.source.author.handle, proposal); + } else if ( + item.type === "scoped-choice.agreed" && item.threadId === proposal.threadId + && item.proposalId === proposal.id && item.cardId === proposal.cardId + && item.optionId === proposal.optionId && item.label === proposal.label + && item.scope === proposal.scope && item.source.author.kind === "member" + ) { + active.delete(item.source.author.handle); + active.set(item.source.author.handle, item); + } else if ( + item.type === "stance.changed" && item.threadId === proposal.threadId + && item.source.author.kind === "member" + && targetsScopedProposal(item, proposal.id, proposal.optionId) + ) active.delete(item.source.author.handle); + } + return [...active.values()]; +} diff --git a/apps/server/src/conversation-plan/event-validation.ts b/apps/server/src/conversation-plan/event-validation.ts new file mode 100644 index 00000000..cf1a35d4 --- /dev/null +++ b/apps/server/src/conversation-plan/event-validation.ts @@ -0,0 +1,304 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { ULID } from "@chopin/dialect"; +import { limits as questionLimits } from "@chopin/question"; +import { assertSourceShape } from "./sources"; +import { assertCorrectionChange } from "./correction-validation"; +import { + actor, + id, + knownKeys, + MAX_EVENTS, + record, + spikeAgreementLabel, + spikePreferenceLabel, + text, + version, +} from "./validation-fields"; + +export function assertEventShape(value: unknown): asserts value is ConversationPlan.Event { + let event = record(value); + let base = ["id", "type", "threadId", "observedThreadVersion", "origin", "actor", "at"]; + id(event.id); + id(event.threadId); + version(event.observedThreadVersion); + version(event.at); + actor(event.actor); + if (!["classifier", "planner", "human"].includes(event.origin as string)) { + throw new Error("invalid event origin"); + } + let kind = (event.actor as { kind: string }).kind; + if ( + (event.origin === "classifier" && kind !== "classifier") + || (event.origin === "planner" && kind !== "agent") + || (event.origin === "human" && kind !== "member") + ) throw new Error("event origin and actor disagree"); + if (event.source !== undefined) assertSourceShape(event.source); + let humanExcerpt = event.origin === "human" + && ["option.added", "reason.added", "constraint.added"].includes(event.type as string) + && event.source !== undefined + && (event.contribution as { authoring?: unknown } | undefined)?.authoring === "quoted"; + if ( + event.origin === "human" && (!humanExcerpt && event.source !== undefined || ![ + "decision.recorded", + "decision.reopened", + "thread.discarded", + "scoped-choice.saved", + "option.added", + "reason.added", + "constraint.added", + "candidate.confirmed", + "candidate.rejected", + "card.corrected", + ].includes(event.type as string)) + ) throw new Error("human action cannot claim a message quote"); + switch (event.type) { + case "thread.opened": + knownKeys(event, [...base, "source", "question"]); + if (event.source === undefined) { + if (event.origin !== "planner") throw new Error("opening requires a question source"); + } else assertSourceShape(event.source); + text(event.question, event.source === undefined ? 1_000 : 500); + break; + case "option.added": + case "reason.added": + case "constraint.added": { + knownKeys(event, [...base, "source", "contribution"]); + let cardOption = event.type === "option.added" && event.origin === "planner" + && typeof event.id === "string" + && /^card:[0-9A-HJKMNP-TV-Z]{26}:option:[0-9A-HJKMNP-TV-Z]{26}$/.test(event.id); + let inferred = event.origin === "classifier" + || (event.origin === "planner" && event.source !== undefined + && /^classifier:[0-9a-f]{24}$/.test(event.id as string)); + if (inferred) { + assertSourceShape(event.source); + if (event.origin === "planner" && event.source.author.kind !== "agent") { + throw new Error("Planner quote must come from an agent message"); + } + } else if ( + !humanExcerpt && (event.type !== "option.added" + || event.source !== undefined && !cardOption) + ) { + throw new Error("only card options can omit a source"); + } + let contribution = record(event.contribution); + knownKeys(contribution, ["id", "text", "authoring", "targetId", "relation"]); + id(contribution.id); + if (cardOption && !String(event.id).endsWith(`:option:${contribution.id}`)) { + throw new Error("card option event ID disagrees with contribution"); + } + if (!inferred && !ULID.test(contribution.id as string)) { + throw new Error("card option IDs must be ULIDs"); + } + text(contribution.text); + if (!["quoted", "scribe", "human-edited"].includes(contribution.authoring as string)) { + throw new Error("invalid contribution authoring"); + } + if ( + humanExcerpt && contribution.authoring !== "quoted" + || !inferred && !humanExcerpt && ( + (event.origin === "human" && contribution.authoring !== "human-edited") + || (event.origin === "planner" && contribution.authoring !== "scribe") + ) + ) throw new Error("card option authoring disagrees with origin"); + if (contribution.targetId !== undefined) id(contribution.targetId); + if ( + contribution.relation !== undefined + && !["supports", "challenges", "qualifies"].includes(contribution.relation as string) + ) throw new Error("invalid contribution relation"); + break; + } + case "stance.changed": + knownKeys(event, [...base, "source", "optionId", "scopedProposalId", "position"]); + assertSourceShape(event.source); + if (event.optionId !== undefined) id(event.optionId); + if (event.scopedProposalId !== undefined && event.scopedProposalId !== null) { + id(event.scopedProposalId); + } + if (!["support", "oppose", "neutral"].includes(event.position as string)) { + throw new Error("invalid stance"); + } + break; + case "option.relabeled": + knownKeys(event, [...base, "optionId", "label", "observedCardRevision"]); + id(event.optionId); + version(event.observedCardRevision); + text(event.label, questionLimits.MAX_LABEL); + let label = event.label as string; + if ( + event.origin !== "planner" || label !== label.trim() + || /[\r\n]/.test(label) + || /\b(?:and|or|versus|vs\.?)\b/i.test(label) + || /,[^,]+,|,[^,]+\band\b/i.test(label) + || /(?<=[a-z0-9])\s*\/\s*(?=[a-z0-9])/i.test(label) + ) throw new Error("invalid atomic option label"); + break; + case "thread.leaning": + knownKeys(event, [...base, "source", "optionId"]); + assertSourceShape(event.source); + if (event.optionId !== undefined) id(event.optionId); + break; + case "card.linked": + knownKeys(event, [...base, "questionnaireId"]); + id(event.questionnaireId); + if (event.origin !== "classifier") throw new Error("card link is a system event"); + break; + case "settle.suggested": + knownKeys(event, [...base, "source", "optionId"]); + assertSourceShape(event.source); + id(event.optionId); + if (event.origin !== "classifier") throw new Error("settle suggestion is inferred"); + break; + case "settle.agreed": + knownKeys(event, [...base, "source", "optionId"]); + assertSourceShape(event.source); + id(event.optionId); + if (event.origin !== "classifier") throw new Error("agreement is inferred"); + break; + case "settle.deferred": + knownKeys(event, [...base, "source", "proposalId"]); + assertSourceShape(event.source); + id(event.proposalId); + if (event.origin !== "classifier" || event.source.role !== "constraint") { + throw new Error("deferral requires a sourced constraint"); + } + break; + case "settle.resumed": + knownKeys(event, [...base, "source", "proposalId", "deferredEventId"]); + assertSourceShape(event.source); + id(event.proposalId); + id(event.deferredEventId); + if (event.origin !== "classifier" || event.source.role !== "verification") { + throw new Error("resume requires sourced verification"); + } + break; + case "scoped-choice.proposed": + knownKeys(event, [...base, "source", "cardId", "optionId", "label", "scope"]); + assertSourceShape(event.source); + id(event.cardId); + id(event.optionId); + text(event.label, questionLimits.MAX_LABEL); + if ( + event.origin !== "classifier" || event.scope !== "spike" + || event.source.role !== "support" || event.source.author.kind !== "member" + || spikePreferenceLabel(event.source.quote)?.toLocaleLowerCase() + !== (event.label as string).toLocaleLowerCase() + ) throw new Error("invalid scoped choice proposal"); + break; + case "scoped-choice.agreed": + knownKeys(event, [ + ...base, + "source", + "proposalId", + "cardId", + "optionId", + "label", + "scope", + ]); + assertSourceShape(event.source); + id(event.proposalId); + id(event.cardId); + id(event.optionId); + text(event.label, questionLimits.MAX_LABEL); + if ( + event.origin !== "classifier" || event.scope !== "spike" + || event.source.role !== "support" || event.source.author.kind !== "member" + || spikeAgreementLabel(event.source.quote)?.toLocaleLowerCase() + !== (event.label as string).toLocaleLowerCase() + ) throw new Error("invalid scoped choice agreement"); + break; + case "scoped-choice.saved": { + knownKeys(event, [ + ...base, + "proposalId", + "supportEventIds", + "agreementId", + "cardId", + "optionId", + "label", + "scope", + "sources", + "expectedGeneration", + "expectedLabel", + ]); + id(event.proposalId); + if (event.supportEventIds !== undefined) { + if ( + !Array.isArray(event.supportEventIds) || event.supportEventIds.length < 1 + || event.supportEventIds.length > MAX_EVENTS + || new Set(event.supportEventIds).size !== event.supportEventIds.length + ) throw new Error("invalid saved scoped choice source IDs"); + for (let sourceId of event.supportEventIds) id(sourceId); + } + if (event.agreementId !== undefined) id(event.agreementId); + id(event.cardId); + id(event.optionId); + text(event.label, questionLimits.MAX_LABEL); + version(event.expectedGeneration); + if (event.expectedLabel !== undefined) text(event.expectedLabel, questionLimits.MAX_LABEL); + if ( + event.origin !== "human" || event.scope !== "spike" + || !Array.isArray(event.sources) + || (event.supportEventIds === undefined + ? event.sources.length !== (event.agreementId === undefined ? 1 : 2) + : event.agreementId !== undefined + || event.sources.length !== event.supportEventIds.length) + ) throw new Error("invalid saved scoped choice"); + let members = new Set(); + for (let source of event.sources) { + assertSourceShape(source); + if (source.role !== "support" || source.author.kind !== "member") { + throw new Error("invalid saved scoped choice source"); + } + if (event.supportEventIds !== undefined) { + if (members.has(source.author.handle)) throw new Error("duplicate scoped supporter"); + members.add(source.author.handle); + } + } + break; + } + case "thread.discarded": + knownKeys(event, base); + if (event.origin !== "human") throw new Error("only a person discards"); + break; + case "decision.recorded": + knownKeys(event, [...base, "source", "text", "optionId", "explicit"]); + text(event.text); + if (event.optionId !== undefined) id(event.optionId); + if (event.explicit !== true) throw new Error("decision requires explicit resolution"); + if (event.origin !== "human") assertSourceShape(event.source); + break; + case "decision.reopened": + knownKeys(event, [...base, "source", "explicit"]); + if (event.explicit !== true) throw new Error("reopening requires explicit statement"); + if (event.origin !== "human") assertSourceShape(event.source); + break; + case "candidate.proposed": { + knownKeys(event, [...base, "source", "candidate"]); + assertSourceShape(event.source); + let candidate = record(event.candidate); + knownKeys(candidate, ["id", "kind", "text"]); + id(candidate.id); + text(candidate.text); + if (!["resolution", "reopening"].includes(candidate.kind as string)) { + throw new Error("invalid candidate kind"); + } + break; + } + case "candidate.confirmed": + case "candidate.rejected": + knownKeys(event, [...base, "candidateId"]); + id(event.candidateId); + if (event.origin !== "human") throw new Error("candidate action requires human"); + break; + case "card.corrected": + knownKeys(event, [...base, "change"]); + if (event.origin !== "human") throw new Error("card correction requires human"); + assertCorrectionChange(event.change); + if ( + ["record-decision", "confirm-candidate", "reject-candidate"].includes(event.change.kind) + ) throw new Error("invalid card correction event"); + break; + default: + throw new Error("invalid conversation plan event type"); + } +} diff --git a/apps/server/src/conversation-plan/events.test.ts b/apps/server/src/conversation-plan/events.test.ts new file mode 100644 index 00000000..a2c3d425 --- /dev/null +++ b/apps/server/src/conversation-plan/events.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import { effectivePending, effectivePreference } from "./preference"; + +function empty(): ConversationPlan.State { + return { schemaVersion: 1, revision: 0, events: [], threads: [], queue: [], analysis: [] }; +} +function opening( + threadId = "thread-1", + id = "open-1", +): Extract { + return { + id, + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + type: "thread.opened", + question: "Which runtime?", + }; +} + +test("replay clones an accepted event and advances the initial thread and revision", () => { + let before = empty(); + let event = opening(); + let after = applyEvent(before, event); + expect(before).toEqual(empty()); + expect(after.revision).toBe(1); + expect(after.threads[0]!.version).toBe(1); + expect(after.threads[0]!.question).toBe("Which runtime?"); + event.question = "Mutated caller input"; + expect(after.threads[0]!.question).toBe("Which runtime?"); + expect(after.events[0]).not.toBe(event); +}); + +function citation( + role: ConversationPlan.SourceRole, + quote = "Bun", + handle = "maggie", + messageId = "message-1", +): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle }, + quote, + start: 0, + end: quote.length, + role, + }; +} +function accept( + state: ConversationPlan.State, + body: Record, + threadId = "thread-1", + human = false, +): ConversationPlan.State { + return applyEvent(state, { + id: `event-${state.events.length}`, + threadId, + observedThreadVersion: state.threads.find(thread => thread.id === threadId)!.version, + origin: human ? "human" : "classifier", + actor: human ? { kind: "member", handle: "maggie" } : { kind: "classifier" }, + at: state.events.length, + ...body, + } as ConversationPlan.Event); +} +function withOption(): ConversationPlan.State { + return accept(applyEvent(empty(), opening()), { + type: "option.added", + source: citation("option"), + contribution: { id: "option-1", text: "Bun", authoring: "quoted" }, + }); +} + +test("event ID deduplication preserves the inherited return-before-validation behavior", () => { + let current = applyEvent(empty(), opening()); + let duplicate = { ...opening(), question: "Different body", observedThreadVersion: 99 }; + expect(applyEvent(current, duplicate)).toBe(current); + expect(applyEvent(current, { ...duplicate, question: "" })).toBe(current); + expect(() => + applyEvent(current, { + id: "stale", + type: "thread.discarded", + threadId: "thread-1", + observedThreadVersion: 0, + at: 1, + origin: "human", + actor: { kind: "member", handle: "maggie" }, + }) + ).toThrow("stale conversation thread version"); + expect(() => applyEvent(current, { ...opening(), id: "different" })).toThrow( + "stale thread opening", + ); + expect(() => + applyEvent(current, { + id: "missing", + type: "thread.discarded", + threadId: "missing", + observedThreadVersion: 0, + at: 1, + origin: "human", + actor: { kind: "member", handle: "maggie" }, + }) + ).toThrow("stale conversation thread version"); +}); + +test("contribution, stance, link, and relabel replay retain exact source wording", () => { + let original = withOption(); + let before = structuredClone(original); + let current = accept(original, { type: "card.linked", questionnaireId: "card-1" }); + current = accept(current, { + type: "stance.changed", + source: citation("support"), + optionId: "option-1", + position: "support", + }); + current = accept(current, { + type: "stance.changed", + source: citation("support", "Bun", "alex", "message-2"), + optionId: "option-1", + position: "support", + }); + current = accept(current, { + type: "thread.leaning", + source: citation("support"), + optionId: "option-1", + }); + current = accept(current, { + type: "option.relabeled", + optionId: "option-1", + label: "Bun runtime", + observedCardRevision: 0, + origin: "planner", + actor: { kind: "agent" }, + }); + expect(original).toEqual(before); + expect(current.threads[0]!.status).toBe("leaning"); + expect(current.threads[0]!.contributions[0]!.text).toBe("Bun"); + expect(current.threads[0]!.contributions[0]!.displayLabel).toBe("Bun runtime"); + expect(current.threads[0]!.stanceHistory).toHaveLength(2); + expect(() => + accept(current, { + type: "stance.changed", + source: citation("support"), + optionId: "unknown", + position: "support", + }) + ).toThrow("unknown stance option"); + expect(() => + accept(current, { + type: "reason.added", + source: citation("reason"), + contribution: { id: "reason-1", text: "Different", authoring: "quoted" }, + }) + ).toThrow("quoted wording must match source"); +}); + +test("candidate confirmation, explicit reopening, and discard preserve decision history", () => { + let current = withOption(); + current = accept(current, { + type: "candidate.proposed", + source: citation("resolution", "Use Bun"), + candidate: { id: "candidate-1", kind: "resolution", text: "Use Bun" }, + }); + current = accept( + current, + { type: "candidate.confirmed", candidateId: "candidate-1" }, + "thread-1", + true, + ); + expect(current.threads[0]!.status).toBe("decided"); + expect(current.threads[0]!.decision!.text).toBe("Use Bun"); + current = accept(current, { type: "decision.reopened", explicit: true }, "thread-1", true); + expect(current.threads[0]!.decision).toBeUndefined(); + expect(current.threads[0]!.decisionHistory).toHaveLength(1); + current = accept(current, { type: "thread.discarded" }, "thread-1", true); + expect(current.threads[0]!.status).toBe("discarded"); +}); + +test("settle deferral blocks effective preference until its exact verification resumes it", () => { + let current = withOption(); + current = accept(current, { + type: "settle.suggested", + source: citation("resolution", "Use Bun", "maggie", "proposal-message"), + optionId: "option-1", + }); + let proposalId = current.events.at(-1)!.id; + current = accept(current, { + type: "stance.changed", + source: citation("withdrawal", "Wait", "maggie", "defer-message"), + optionId: "option-1", + position: "neutral", + }); + current = accept(current, { + type: "settle.deferred", + source: citation("constraint", "Wait", "maggie", "defer-message"), + proposalId, + }); + let deferredEventId = current.events.at(-1)!.id; + expect(effectivePending(current.threads[0]!, current.events)).toBeUndefined(); + expect(effectivePreference(current.threads[0]!, current.events)).toBeUndefined(); + expect(() => + accept(current, { + type: "settle.resumed", + source: citation("verification", "Verified"), + proposalId, + deferredEventId: "wrong", + }) + ).toThrow("verification does not target the active deferral"); + current = accept(current, { + type: "settle.resumed", + source: citation("verification", "Verified"), + proposalId, + deferredEventId, + }); + expect(effectivePending(current.threads[0]!, current.events)?.optionId).toBe("option-1"); + expect(effectivePreference(current.threads[0]!, current.events)).toEqual({ + optionId: "option-1", + messageIds: ["proposal-message"], + }); +}); + +test("moving a contribution advances both threads once and rejects stale targets", () => { + let current = withOption(); + current = applyEvent(current, opening("thread-2", "open-2")); + let before = structuredClone(current); + let fromVersion = current.threads[0]!.version; + let targetVersion = current.threads[1]!.version; + expect(() => + accept( + current, + { + type: "card.corrected", + change: { + kind: "move", + contributionId: "option-1", + targetThreadId: "thread-2", + targetVersion: targetVersion + 1, + }, + }, + "thread-1", + true, + ) + ).toThrow("stale target thread version"); + let next = accept( + current, + { + type: "card.corrected", + change: { + kind: "move", + contributionId: "option-1", + targetThreadId: "thread-2", + targetVersion, + }, + }, + "thread-1", + true, + ); + expect(current).toEqual(before); + expect(next.threads[0]!.contributions).toHaveLength(0); + expect(next.threads[1]!.contributions[0]!.targetId).toBe("thread-2"); + expect(next.threads[0]!.version).toBe(fromVersion + 1); + expect(next.threads[1]!.version).toBe(targetVersion + 1); + expect(next.revision).toBe(current.revision + 1); + expect(next.events).toHaveLength(current.events.length + 1); + let later = accept( + next, + { + type: "card.corrected", + change: { kind: "edit", field: "question", text: "Which editor?" }, + }, + "thread-2", + true, + ); + expect(later.threads[1]!.question).toBe("Which editor?"); + expect(next.threads[1]!.question).toBe("Which runtime?"); + expect(current).toEqual(before); +}); diff --git a/apps/server/src/conversation-plan/events.ts b/apps/server/src/conversation-plan/events.ts new file mode 100644 index 00000000..9f8f71ec --- /dev/null +++ b/apps/server/src/conversation-plan/events.ts @@ -0,0 +1,110 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { assertEventShape, MAX_EVENTS, MAX_THREADS } from "./validation"; +import { applyContributionEvent } from "./event-contributions"; +import { applyLifecycleEvent } from "./event-lifecycle"; +import { applySettlementEvent } from "./event-settlement"; +import { applyCorrectionEvent } from "./event-corrections"; + +export { activeScopedSupport, currentScopedProposal, targetsScopedProposal } from "./event-support"; + +type State = ConversationPlan.State; +type Event = ConversationPlan.Event; + +export class ConversationCapacityError extends Error { + constructor() { + super("Conversation history is full"); + } +} + +/** Durable card actions reserve the event slots their FIFO mirror will need. */ +export function assertEventCapacity(state: State, reserved: number): void { + if (state.events.length + reserved > MAX_EVENTS) throw new ConversationCapacityError(); +} + +/** One accepted event changes a cloned view; callers persist the returned state. */ +export function applyEvent(state: State, event: Event): State { + if (state.events.some((accepted) => accepted.id === event.id)) return state; + assertEventShape(event); + event = structuredClone(event); + assertEventCapacity(state, 1); + let existing = state.threads.find((thread) => thread.id === event.threadId); + if (event.type === "thread.opened") { + if (existing || event.observedThreadVersion !== 0) throw new Error("stale thread opening"); + if (state.threads.length >= MAX_THREADS) throw new Error("conversation thread limit reached"); + if (event.source && event.source.role !== "question") { + throw new Error("opening requires a question source"); + } + let thread: ConversationPlan.Thread = { + id: event.threadId, + question: event.question, + questionSources: event.source ? [event.source] : [], + questionAuthoring: event.source && event.question === event.source.quote + ? "quoted" + : "scribe", + status: "exploring", + contributions: [], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 1, + }; + return { + ...state, + revision: state.revision + 1, + events: [...state.events, event], + threads: [...state.threads, thread], + }; + } + if (!existing || existing.version !== event.observedThreadVersion) { + throw new Error("stale conversation thread version"); + } + let threads = [...state.threads]; + let threadIndex = threads.indexOf(existing); + threads[threadIndex] = structuredClone(existing); + // A move also changes its destination. Other threads and sidecars are read-only here. + if (event.type === "card.corrected" && event.change.kind === "move") { + let targetThreadId = event.change.targetThreadId; + let targetIndex = threads.findIndex((item) => item.id === targetThreadId); + if (targetIndex >= 0 && targetIndex !== threadIndex) { + threads[targetIndex] = structuredClone(threads[targetIndex]); + } + } + let next: State = { ...state, threads, events: [...state.events] }; + let thread = threads[threadIndex]; + switch (event.type) { + case "option.added": + case "reason.added": + case "constraint.added": + case "stance.changed": + case "option.relabeled": + applyContributionEvent(next, thread, event); + break; + case "thread.leaning": + case "decision.recorded": + case "decision.reopened": + case "candidate.proposed": + case "candidate.confirmed": + case "candidate.rejected": + case "card.linked": + case "thread.discarded": + applyLifecycleEvent(thread, event); + break; + case "settle.suggested": + case "settle.agreed": + case "settle.deferred": + case "settle.resumed": + case "scoped-choice.proposed": + case "scoped-choice.agreed": + case "scoped-choice.saved": + applySettlementEvent(next, thread, event); + break; + case "card.corrected": + applyCorrectionEvent(next, thread, event); + break; + } + thread.version++; + next.revision++; + next.events.push(event); + return next; +} diff --git a/apps/server/src/conversation-plan/interpret-batch.test.ts b/apps/server/src/conversation-plan/interpret-batch.test.ts new file mode 100644 index 00000000..f78eaac0 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-batch.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { interpretMessage } from "./interpret"; +import { initialState } from "./domain"; +import type { JevQuestion } from "./jev"; +import { message, mockResult, seeded, settledBy } from "./interpret.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, pipeline.test.ts. +// Complete callbacks and helpers retained; injected offline transport only. + +test("a failed target waits for its sibling before the next message starts", async () => { + let current = message( + "failed-batch", + "Sounds good to me. What about agents? Copilot?", + "Jules", + ); + let next = message("next-message", "Should we use GitHub authentication?", "Mina"); + let release!: (result: ReturnType) => void; + let held = new Promise>(resolve => release = resolve); + let heldQuestions!: Record; + let targetCalls = 0; + let nextStarted = false; + let first = interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: settledBy(seeded(), "Mina"), + ask: request => { + if ("new_question" in request.questions) { + return Promise.resolve(mockResult(request.questions, { new_question: 0.95 })); + } + targetCalls++; + if ("c0_role" in request.questions) { + heldQuestions = request.questions; + return held; + } + if ("c1_role" in request.questions) return Promise.reject(new Error("target failed")); + return Promise.resolve(mockResult(request.questions, {})); + }, + }); + let second = first.then(() => + interpretMessage({ + channelId: "channel", + message: next, + recent: [current], + state: initialState(), + ask: async request => { + nextStarted = true; + return mockResult(request.questions, {}); + }, + }) + ); + await Bun.sleep(0); + expect(targetCalls).toBe(3); + expect(nextStarted).toBe(false); + release(mockResult(heldQuestions, {})); + let failed = await first; + await second; + expect(failed.events).toEqual([]); + expect(failed.analysis.status).toBe("failed"); + expect(failed.analysis.passes).toHaveLength(1); + expect(nextStarted).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/interpret-order.test.ts b/apps/server/src/conversation-plan/interpret-order.test.ts new file mode 100644 index 00000000..497616b6 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-order.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { postmarkId, sesId, stateWithOptions } from "./research-offers.test-fixtures"; +import { interpretMessage } from "./interpret"; +import { message, mockResult } from "./interpret.test-fixtures"; + +// Preserve original targeting dispatch turn; helper extraction must introduce no extra await. +test.each([false, true])( + "research eligible %s retains targeting dispatch before the next queued observation", + async eligible => { + let targetingCalls = 0; + let observe!: (count: number) => void; + let observed = new Promise(resolve => observe = resolve); + let pending = interpretMessage({ + channelId: "channel", + message: message("dispatch-order", "Should we use GitHub authentication?"), + recent: [], + state: stateWithOptions("auth", "Which authentication?", [ + { id: postmarkId, label: "GitHub" }, + { id: sesId, label: "Email" }, + ]), + ask: request => { + let triage = "new_question" in request.questions; + let research = "research_kind" in request.questions; + if ((triage && !eligible) || research) { + queueMicrotask(() => queueMicrotask(() => observe(targetingCalls))); + } + if (!triage && !research) targetingCalls++; + return Promise.resolve( + mockResult( + request.questions, + triage ? { new_question: 0.95, research_need: eligible ? 0.95 : 0.05 } : {}, + ), + ); + }, + }); + expect(await observed).toBe(1); + let output = await pending; + expect(output.analysis.passes.map(pass => pass.stage)).toEqual(["triage", "targeting"]); + expect(output.researchOffer).toBeUndefined(); + }, +); diff --git a/apps/server/src/conversation-plan/interpret-research.ts b/apps/server/src/conversation-plan/interpret-research.ts new file mode 100644 index 00000000..95b8d3fe --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-research.ts @@ -0,0 +1,84 @@ +import type { Chat } from "@chopin/protocol"; +import type { JevResult } from "./jev"; +import type { InterpretInput, ResearchOfferCandidate } from "./interpret-types"; +import { confidentChoice, noul } from "./interpret-scoring"; +import { MAX_QUOTE_CANDIDATES } from "./quote-budget"; +import type { QuoteCandidate } from "./quotes"; + +export type MemberResearchInput = InterpretInput & { + message: Chat.Entry & { author: Extract }; +}; + +/** The main interpreter retains the original guard, request await and research catch. */ +export function selectResearchOffer( + input: MemberResearchInput, + first: JevResult, + quotes: QuoteCandidate[], + result: JevResult, +): ResearchOfferCandidate | undefined { + let researchOffer: ResearchOfferCandidate | undefined; + if (result.model === first.model) { + let answers = result.answers; + let quoteKey = confidentChoice(answers, "research_quote"); + let threadId = confidentChoice(answers, "research_thread"); + let firstId = confidentChoice(answers, "research_option_a"); + let secondId = confidentChoice(answers, "research_option_b"); + let kind = confidentChoice(answers, "research_kind"); + let selectedQuote = quoteKey?.match(/^q(\d+)$/)?.[1]; + let index = selectedQuote && Number(selectedQuote) < MAX_QUOTE_CANDIDATES + ? selectedQuote + : undefined; + let quote = index === undefined ? undefined : quotes[Number(index)]; + let answered = answers[`research_q${index}_answered`]; + let scope = confidentChoice(answers, `research_q${index}_scope`); + let thread = input.state.threads.find(item => item.id === threadId); + let options = thread?.contributions.filter(item => item.kind === "option") ?? []; + let source = quote && { + messageId: input.message.id, + author: input.message.author, + quote: quote.quote, + start: quote.start, + end: quote.end, + }; + let grounded = quote && thread && source + && ["exploring", "leaning", "reopened"].includes(thread.status) + && noul(first.answers, `c${index}_owned_unretracted`) >= 0.8 + && noul(answers, `research_q${index}_owned`) >= 0.8 + && answered?.type === "noul" && answered.noul <= 0.2 + && /\b(?:costs?|pric(?:e|es|ing)|egress|billing|fees?|rates?)\b/i.test(quote.quote); + if ( + grounded && source && thread + && firstId && secondId && firstId !== secondId + && options.length === 2 + && options.some(item => item.id === firstId) + && options.some(item => item.id === secondId) + && kind === "current-cost-comparison" + ) { + researchOffer = { + source, + threadId: thread.id, + optionIds: [firstId, secondId], + }; + } else if ( + grounded && source && thread && kind === "current-cost-concern" + && [3, 4].includes(options.length) + && input.state.threads.filter(item => + ["exploring", "leaning", "reopened"].includes(item.status) + && [2, 3, 4].includes( + item.contributions.filter(value => value.kind === "option").length, + ) + ).length === 1 + && scope && scope !== "none" + && (scope === "all-current" || options.some(item => item.id === scope)) + ) { + researchOffer = { + kind: "current-cost-concern", + source, + threadId: thread.id, + optionIds: options.map(item => item.id), + ...(scope === "all-current" ? {} : { focusOptionId: scope }), + }; + } + } + return researchOffer; +} diff --git a/apps/server/src/conversation-plan/interpret-scoring.ts b/apps/server/src/conversation-plan/interpret-scoring.ts new file mode 100644 index 00000000..da824637 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-scoring.ts @@ -0,0 +1,23 @@ +import type { JevAnswer } from "./jev"; + +export function noul(answers: Record, key: string): number { + let answer = answers[key]; + return answer?.type === "noul" ? answer.noul : 0; +} +export function score(answers: Record, key: string): number { + let answer = answers[key]; + return answer?.type === "score" ? answer.score : 0; +} +export function confidentChoice( + answers: Record, + key: string, +): string | undefined { + let answer = answers[key]; + if (answer?.type !== "choice") return; + let ranked = Object.values(answer.probabilities).sort((a, b) => b - a); + if ( + answer.confidence < 0.8 || (ranked[0] ?? 0) < 0.8 + || (ranked[0] ?? 0) - (ranked[1] ?? 0) < 0.2 + ) return; + return answer.choice; +} diff --git a/apps/server/src/conversation-plan/interpret-source.test.ts b/apps/server/src/conversation-plan/interpret-source.test.ts new file mode 100644 index 00000000..87bbf80f --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-source.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { interpretMessage } from "./interpret"; +import { initialState } from "./domain"; +import type { JevQuestion } from "./jev"; +import { message, mockResult, seeded, settledBy } from "./interpret.test-fixtures"; +import { extractQuotes } from "./quotes"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, pipeline.test.ts. +// Complete callbacks and helpers retained; injected offline transport only. + +test("rejects a fifth source quote before Jev or any partial event", async () => { + let current = message( + "five-quotes", + "Use GitHub OAuth. Use magic links. Send user tokens. Use a GitHub App. Keep credentials separate.", + ); + let calls = 0; + expect(() => extractQuotes(current.text)).toThrow("source quote count exceeds 4"); + + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: initialState(), + ask: async request => { + calls++; + return mockResult(request.questions, {}); + }, + }); + + expect(calls).toBe(0); + expect(output.events).toEqual([]); + expect(output.analysis.status).toBe("failed"); +}); + +test("a late retraction beyond the ownership context fails closed before Jev", async () => { + let calls = 0; + let ask = async (request: { questions: Record }) => { + calls++; + return mockResult(request.questions, {}); + }; + let tooLong = message( + "late-retraction", + `Sounds good to me. ${"x".repeat(4000)} Actually, no—I withdraw that.`, + "Jules", + ); + let blocked = await interpretMessage({ + channelId: "channel", + message: tooLong, + recent: [], + state: settledBy(seeded(), "Mina"), + ask, + }); + expect(blocked.events).toEqual([]); + expect(blocked.analysis).toMatchObject({ + status: "unlinked", + policyGate: "message exceeds ownership context", + passes: [], + }); + expect(calls).toBe(0); + let boundary = message("exact-boundary", `${"x".repeat(3999)}.`, "Jules"); + await interpretMessage({ + channelId: "channel", + message: boundary, + recent: [], + state: initialState(), + ask, + }); + expect(calls).toBe(1); +}); diff --git a/apps/server/src/conversation-plan/interpret-targeting.test.ts b/apps/server/src/conversation-plan/interpret-targeting.test.ts new file mode 100644 index 00000000..1bdc44b0 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-targeting.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { interpretMessage } from "./interpret"; +import type { JevQuestion } from "./jev"; +import { message, mockResult, seeded, settledBy, stateWithOption } from "./interpret.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, pipeline.test.ts. +// Complete callbacks and helpers retained; injected offline transport only. + +test("interprets a declarative pair for a different open topic through both Jev passes", async () => { + let state = stateWithOption( + "Which search service should we use?", + "search-thread", + "existing-search", + "Use PostgreSQL search.", + ); + let current = message("search-alternatives", "By search I mean Algolia or Meilisearch."); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { new_option: 0.05, thread_target: "search-thread", significance: 2 } + : { + c0_role: "none", + c1_role: "none", + c0_thread: "search-thread", + c1_thread: "search-thread", + c0_new_option: 0.05, + c1_new_option: 0.85, + }, + ), + }); + expect(calls).toBe(3); + expect(output.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(output.events.map(event => "source" in event && event.source)).toMatchObject([ + { quote: "Algolia", start: 17, end: 24, role: "option" }, + { quote: "Meilisearch", start: 28, end: 39, role: "option" }, + ]); +}); + +test("a later target failure or inconsistent model cannot publish partial events", async () => { + let current = message("partial", "Sounds good to me. What about agents? Copilot?", "Jules"); + for (let failure of ["transport", "model"] as const) { + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [message("proposal", "Let's use GitHub.", "Mina")], + state: settledBy(seeded(), "Mina"), + ask: async request => { + calls++; + if (calls === 4 && failure === "transport") throw new Error("synthetic failure"); + let result = mockResult(request.questions, { + new_question: 0.95, + c0_owned_unretracted: 0.95, + c1_owned_unretracted: 0.95, + c2_owned_unretracted: 0.95, + c0_role: "support", + c0_thread: "thread-a", + c0_support: 0.95, + c0_agrees_with_settle: 0.95, + c1_role: "question", + c1_thread: "new", + }); + if (calls === 3 && failure === "model") result.model = "other-model"; + return result; + }, + }); + expect(calls).toBe(4); + expect(output.events).toEqual([]); + expect(output.analysis.status).toBe("failed"); + expect(output.analysis.passes).toHaveLength(1); + } +}); + +test("out-of-order target replies retain their quote-specific answers", async () => { + let current = message( + "out-of-order", + "Sounds good to me. What about agents? Copilot?", + "Jules", + ); + let release: Array<(result: ReturnType) => void> = []; + let requests: Array> = []; + let interpretation = interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: settledBy(seeded(), "Mina"), + ask: request => { + if ("new_question" in request.questions) { + return Promise.resolve(mockResult(request.questions, { new_question: 0.95 })); + } + requests.push(request.questions); + return new Promise(resolve => release.push(resolve)); + }, + }); + await Bun.sleep(0); + expect(requests).toHaveLength(3); + for (let index of [2, 1, 0]) { + release[index]!(mockResult(requests[index]!, { [`c${index}_new_option`]: (index + 1) / 10 })); + } + let output = await interpretation; + expect(output.analysis.passes.map(pass => pass.stage)).toEqual(["triage", "targeting"]); + let answers = output.analysis.passes[1]!.answers; + for (let index = 0; index < 3; index++) { + expect(answers[`c${index}_new_option`]).toEqual({ type: "noul", noul: (index + 1) / 10 }); + } +}); diff --git a/apps/server/src/conversation-plan/interpret-types.ts b/apps/server/src/conversation-plan/interpret-types.ts new file mode 100644 index 00000000..9db393c9 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret-types.ts @@ -0,0 +1,31 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevRequest, JevResult } from "./jev"; +import type { LinkedCardOptions } from "./questions"; + +export type Analysis = Omit; +export type ResearchOfferCandidate = { + source: ConversationPlan.ResearchSource; + threadId: string; + optionIds: [string, string]; + kind?: "current-cost-comparison"; +} | { + source: ConversationPlan.ResearchSource; + threadId: string; + optionIds: string[]; + kind: "current-cost-concern"; + focusOptionId?: string; +}; +export type Interpretation = { + events: ConversationPlan.Event[]; + analysis: Analysis; + researchOffer?: ResearchOfferCandidate; +}; +export type InterpretInput = { + channelId: string; + message: Chat.Entry; + recent: readonly Chat.Entry[]; + state: ConversationPlan.State; + linkedCards?: LinkedCardOptions; + /** Injectable transport for domain tests; production defaults to the bounded TypeSafe adapter. */ + ask?: (request: JevRequest) => Promise; +}; diff --git a/apps/server/src/conversation-plan/interpret.test-fixtures.ts b/apps/server/src/conversation-plan/interpret.test-fixtures.ts new file mode 100644 index 00000000..c59984fa --- /dev/null +++ b/apps/server/src/conversation-plan/interpret.test-fixtures.ts @@ -0,0 +1,141 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevQuestion } from "./jev"; +import { optionIdFor } from "./policy"; +import { message } from "./policy-initial.test-fixtures"; +export { message } from "./policy-initial.test-fixtures"; +export { stateWithOption } from "./policy-terminal.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, pipeline.test.ts. +// Complete callbacks and helpers retained; injected offline transport only. +export let seededOptionId = optionIdFor("channel", "seed-option", 0, 1_000); + +export function seeded(): ConversationPlan.State { + let state = initialState(); + let original = message("m1", "Should we use an optional outline?"); + return applyInference(state, { + id: "open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: original.text, + start: 0, + end: original.text.length, + role: "question", + }, + question: original.text, + }, original); +} + +export function withOption(state: ConversationPlan.State): ConversationPlan.State { + let proposal = message("seed-option", "Use an optional outline."); + return applyInference(state, { + id: "seed-option-event", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "option", + }, + contribution: { + id: seededOptionId, + text: proposal.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, proposal); +} + +export function settledBy(state: ConversationPlan.State, handle: string): ConversationPlan.State { + let withFirstOption = withOption(state); + let proposal = message("settle-proposal", "Let's just go with it.", handle); + return applyInference(withFirstOption, { + id: "settle-proposal-event", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: withFirstOption.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + optionId: seededOptionId, + }, proposal); +} + +export function mockResult( + questions: Record, + overrides: Record, +): any { + let answers: Record = {}; + let distributions: Record> = {}; + for (let [key, question] of Object.entries(questions)) { + if (question.type === "noul") { + answers[key] = { + type: "noul", + noul: typeof overrides[key] === "number" + ? overrides[key] + : key.endsWith("_owned_unretracted") + ? 0.95 + : 0.05, + }; + } + if (question.type === "choice") { + let choice = typeof overrides[key] === "string" ? overrides[key] : "none"; + if (!(choice in question.criteria)) choice = Object.keys(question.criteria)[0]; + let probabilities = Object.fromEntries( + Object.keys(question.criteria).map(( + option, + ) => [ + option, + option === choice ? 0.95 : 0.05 / (Object.keys(question.criteria).length - 1), + ]), + ); + answers[key] = { type: "choice", choice, confidence: 0.95, probabilities }; + } + if (question.type === "score") { + let chosen = typeof overrides[key] === "number" ? Math.round(overrides[key]) : 0; + let probabilities = Object.fromEntries( + question.criteria.map((_, i) => [String(i), i === chosen ? 1 : 0]), + ); + answers[key] = { + type: "score", + score: chosen, + confidence: 1, + legend: Object.fromEntries(question.criteria.map((label, i) => [String(i), label])), + probabilities, + }; + } + let answer = answers[key]; + distributions[key] = answer.type === "noul" + ? { yes: answer.noul, no: 1 - answer.noul } + : answer.probabilities; + } + return { + model: "mock-jev", + answers, + distributions, + usage: { input_tokens: 1, output_tokens: 1 }, + latencyMs: 1, + }; +} diff --git a/apps/server/src/conversation-plan/interpret.ts b/apps/server/src/conversation-plan/interpret.ts new file mode 100644 index 00000000..6e42f9b4 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret.ts @@ -0,0 +1,210 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { askJev, type JevAnswer } from "./jev"; +import { planEvents } from "./policy"; +import { + buildCandidateTargetingRequest, + buildResearchOfferRequest, + buildTriageRequest, + QUESTION_SET_VERSION, +} from "./questions"; +import { extractQuotes } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; +import { noul, score } from "./interpret-scoring"; +import { type MemberResearchInput, selectResearchOffer } from "./interpret-research"; +import type { + Analysis, + Interpretation, + InterpretInput, + ResearchOfferCandidate, +} from "./interpret-types"; +export type { Interpretation, InterpretInput, ResearchOfferCandidate } from "./interpret-types"; + +/** Computes a proposal only; the caller owns fenced persistence and publication. */ +export async function interpretMessage(input: InterpretInput): Promise { + let started = performance.now(); + let passes: ConversationPlan.AnalysisPass[] = []; + let modelVersion = "unavailable"; + let base: Omit = { + questionSetVersion: QUESTION_SET_VERSION, + modelVersion, + passes, + }; + if ( + input.message.streaming || input.message.author.kind === "system" || !input.message.text.trim() + ) { + return { + events: [], + analysis: { ...base, status: "unlinked", policyGate: "ineligible message" }, + }; + } + if (input.message.text.length > 4000) { + return { + events: [], + analysis: { ...base, status: "unlinked", policyGate: "message exceeds ownership context" }, + }; + } + let ask = input.ask ?? askJev; + let researchOffer: ResearchOfferCandidate | undefined; + try { + let quotes = extractQuotes(input.message.text); + assertQuoteBudget(quotes); + let first = await ask(buildTriageRequest( + input.message, + input.recent, + input.state.threads, + quotes, + input.state.events, + )); + modelVersion = first.model; + passes.push({ stage: "triage", answers: first.answers }); + if ( + input.message.author.kind === "member" && quotes.length + && noul(first.answers, "research_need") >= 0.8 + ) { + try { + let result = await ask(buildResearchOfferRequest( + input.message, + input.recent, + input.state.threads, + quotes, + input.state.events, + )); + researchOffer = selectResearchOffer(input as MemberResearchInput, first, quotes, result); + } catch { + // An uncertain or unavailable research judgment never blocks planning analysis. + } + } + let triageTarget = first.answers.thread_target; + let rankedTargets = triageTarget?.type === "choice" + ? Object.values(triageTarget.probabilities).sort((a, b) => b - a) + : []; + let cardTarget = triageTarget?.type === "choice" + && (rankedTargets[0] ?? 0) >= 0.8 + && (rankedTargets[0] ?? 0) - (rankedTargets[1] ?? 0) >= 0.2 + ? triageTarget.choice + : undefined; + let linkedCards = new Map( + cardTarget && input.linkedCards?.has(cardTarget) + ? [[cardTarget, input.linkedCards.get(cardTarget)!]] + : [], + ); + let useful = [ + "new_question", + "new_option", + "reason", + "constraint", + "evidence", + "assumption", + "support", + "objection", + "correction", + "withdrawal", + "explicit_resolution", + "reopening", + ].some((key) => noul(first.answers, key) >= 0.55) + || score(first.answers, "significance") >= 1.5; + if (!useful) { + return { + events: [], + researchOffer, + analysis: { + ...base, + modelVersion, + status: "unlinked", + policyGate: "low planning significance", + latencyMs: Math.round(performance.now() - started), + }, + }; + } + if (!quotes.length) { + return { + events: [], + researchOffer, + analysis: { + ...base, + modelVersion, + status: "unlinked", + policyGate: "no bounded source quote", + latencyMs: Math.round(performance.now() - started), + }, + }; + } + let requests = quotes.map((_, index) => + buildCandidateTargetingRequest( + input.message, + input.recent, + input.state.threads, + quotes, + index, + input.state.events, + linkedCards, + ) + ); + let settled = await Promise.allSettled(requests.map(request => ask(request))); + let targeted = settled.map(result => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + if (targeted.some(result => result.model !== first.model)) { + throw new Error("inconsistent Jev model versions"); + } + let answers = Object.assign({}, ...targeted.map(result => result.answers)) as Record< + string, + JevAnswer + >; + if (Object.keys(answers).length > 45) throw new Error("too many targeting answers"); + passes.push({ stage: "targeting", answers: structuredClone(answers) }); + let policyAnswers = { ...answers }; + for (let [index, request] of requests.entries()) { + let key = `c${index}_duplicate`; + if (!(key in request.questions)) policyAnswers[key] = { type: "noul", noul: 0 }; + } + let candidates = quotes.map((quote, index) => ({ + ...quote, + answers: Object.fromEntries( + Object.entries(policyAnswers) + .filter(([key]) => key.startsWith(`c${index}_`)) + .map(([key, answer]) => [key.slice(3), answer]), + ), + })); + let planned = planEvents({ + channelId: input.channelId, + message: input.message, + state: input.state, + linkedCards, + first: first.answers, + candidates, + }); + return { + events: planned.events, + researchOffer, + analysis: { + ...base, + modelVersion: first.model, + status: planned.events.length ? "applied" : "unlinked", + selectedTarget: planned.selectedTarget, + quoteValidation: quotes.map((quote) => ({ + start: quote.start, + end: quote.end, + valid: input.message.text.slice(quote.start, quote.end) === quote.quote, + })), + policyGate: planned.policyGate, + outcomes: planned.outcomes, + candidates: planned.candidates, + latencyMs: Math.round(performance.now() - started), + }, + }; + } catch { + return { + events: [], + researchOffer, + analysis: { + ...base, + modelVersion, + status: "failed", + error: "Jev interpretation failed", + latencyMs: Math.round(performance.now() - started), + }, + }; + } +} diff --git a/apps/server/src/conversation-plan/jev.test.ts b/apps/server/src/conversation-plan/jev.test.ts new file mode 100644 index 00000000..13611182 --- /dev/null +++ b/apps/server/src/conversation-plan/jev.test.ts @@ -0,0 +1,248 @@ +import { expect, test } from "bun:test"; +import { askJev, type JevRequest, validateJevResponse } from "./jev"; + +let request = { + state: "Synthetic", + questions: { flag: { type: "noul" as const, instructions: "Is this true?" } }, +}; +let response = () => + new Response(JSON.stringify({ + model: "jev-1.13.0", + answers: { flag: { type: "noul", noul: 0.9 } }, + usage: { input_tokens: 1, output_tokens: 1 }, + })); + +function delayedFetch(delayMs: number, calls: { count: number }) { + return (_: string, init?: RequestInit): Promise => { + calls.count++; + return new Promise((resolve, reject) => { + let signal = init?.signal; + let timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(response()); + }, delayMs); + function onAbort() { + clearTimeout(timer); + reject(new Error("fetch aborted")); + } + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + }; +} + +test("Jev accepts a response just beyond the former 20-second budget", async () => { + let calls = { count: 0 }; + let result = await askJev(request, { + apiKey: "test-only", + fetch: delayedFetch(20_050, calls), + }); + expect(result.answers.flag).toEqual({ type: "noul", noul: 0.9 }); + expect(calls.count).toBe(1); +}, 35_000); + +test("Jev aborts after its configured budget without retrying", async () => { + let calls = { count: 0 }; + await expect(askJev(request, { + apiKey: "test-only", + timeoutMs: 100, + fetch: delayedFetch(500, calls), + })).rejects.toThrow("Jev request timed out"); + expect(calls.count).toBe(1); +}); + +test("caller abort ends Jev before the timeout", async () => { + let calls = { count: 0 }; + let controller = new AbortController(); + let pending = askJev(request, { + apiKey: "test-only", + timeoutMs: 1_000, + signal: controller.signal, + fetch: delayedFetch(500, calls), + }); + controller.abort(); + await expect(pending).rejects.toThrow("Jev request aborted"); + expect(calls.count).toBe(1); +}); + +test("Jev timeout also bounds a stalled response body", async () => { + let stream = new ReadableStream({ start() {} }); + await expect(askJev(request, { + apiKey: "test-only", + timeoutMs: 100, + fetch: async () => new Response(stream), + })).rejects.toThrow("Jev request timed out"); +}); + +test("Jev normalizes a response body reader error", async () => { + let stream = new ReadableStream({ + start(controller) { + controller.error(new Error("secret response body")); + }, + }); + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => new Response(stream), + })).rejects.toThrow("Jev request failed"); +}); + +test("Jev serializes one authenticated request and returns a validated result", async () => { + let calls = 0; + let result = await askJev(request, { + apiKey: "test-only", + model: "jev-fixture", + fetch: async (url, init) => { + calls++; + expect(url).toBe("https://api.typesafe.ai/v1/systemone"); + expect(init?.method).toBe("POST"); + expect(init?.redirect).toBe("error"); + expect(init?.headers).toEqual({ + Authorization: "Bearer test-only", + "Content-Type": "application/json", + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(JSON.parse(init?.body as string)).toEqual({ model: "jev-fixture", ...request }); + return response(); + }, + }); + expect(calls).toBe(1); + expect(result.model).toBe("jev-1.13.0"); + expect(result.usage).toEqual({ input_tokens: 1, output_tokens: 1 }); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); +}); + +let typedRequest: JevRequest = { + state: { text: "Synthetic" }, + questions: { + flag: request.questions.flag, + action: { type: "choice", instructions: "Choose", criteria: { save: "Save", skip: "Skip" } }, + strength: { type: "score", instructions: "Rate", criteria: ["Low", "High"] }, + }, +}; +function typedResponse() { + return { + model: "jev-fixture", + usage: { input_tokens: 0, output_tokens: 1 }, + answers: { + flag: { type: "noul", noul: 0.9 }, + action: { + type: "choice", + choice: "save", + confidence: 0.8, + probabilities: { save: 0.8, skip: 0.2 }, + }, + strength: { + type: "score", + score: 0.75, + confidence: 0.9, + legend: { "0": "Low", "1": "High" }, + probabilities: { "0": 0.25, "1": 0.75 }, + }, + }, + }; +} + +test("Jev validates noul, winning choice, and weighted score answers", () => { + let value = typedResponse(); + expect(validateJevResponse(value, typedRequest.questions)).toEqual({ + ...value, + latencyMs: 0, + }); +}); + +test("Jev rejects malformed answers instead of returning partial results", () => { + let mutations: ((value: ReturnType) => void)[] = [ + (value) => { + value.model = ""; + }, + (value) => { + value.usage.input_tokens = -1; + }, + (value) => { + value.answers.flag.noul = Number.NaN; + }, + (value) => { + Object.assign(value.answers.flag, { extra: true }); + }, + (value) => { + Object.assign(value.answers, { extra: { type: "noul", noul: 0.5 } }); + }, + (value) => { + value.answers.flag.type = "choice"; + }, + (value) => { + value.answers.action.choice = "skip"; + }, + (value) => { + value.answers.action.confidence = 1.1; + }, + (value) => { + value.answers.action.probabilities.save = 0.1; + }, + (value) => { + Object.assign(value.answers.action.probabilities, { other: 0 }); + }, + (value) => { + value.answers.strength.legend["0"] = "Wrong"; + }, + (value) => { + value.answers.strength.score = 0.1; + }, + ]; + for (let mutate of mutations) { + let value = typedResponse(); + mutate(value); + expect(() => validateJevResponse(value, typedRequest.questions)).toThrow("invalid Jev"); + } +}); + +test("Jev rejects request bounds before invoking transport", async () => { + let calls = 0; + let options = { + apiKey: "test-only", + fetch: async () => { + calls++; + return response(); + }, + }; + let invalid: JevRequest[] = [ + { ...request, questions: {} }, + { ...request, state: "x".repeat(24001) }, + { ...request, questions: { "invalid id": request.questions.flag } }, + { ...request, questions: { flag: { ...request.questions.flag, instructions: "" } } }, + { + state: "", + questions: { action: { type: "choice", instructions: "Choose", criteria: { only: "One" } } }, + }, + { + state: "", + questions: { strength: { type: "score", instructions: "Rate", criteria: ["One"] } }, + }, + ]; + for (let value of invalid) await expect(askJev(value, options)).rejects.toThrow("invalid Jev"); + await expect(askJev(request, { ...options, model: "invalid/model" })).rejects.toThrow( + "invalid Jev model selection", + ); + await expect(askJev(request, { ...options, timeoutMs: 99 })).rejects.toThrow( + "invalid Jev timeout", + ); + expect(calls).toBe(0); +}); + +test("Jev rejects oversized and malformed response bodies", async () => { + for ( + let value of [ + new Response("{}", { headers: { "content-length": "262145" } }), + new Response("x".repeat(262145)), + ] + ) { + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => value, + })).rejects.toThrow("Jev response is too large"); + } + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => new Response("{broken"), + })).rejects.toThrow("invalid Jev JSON"); +}); diff --git a/apps/server/src/conversation-plan/jev.ts b/apps/server/src/conversation-plan/jev.ts new file mode 100644 index 00000000..6f47ed89 --- /dev/null +++ b/apps/server/src/conversation-plan/jev.ts @@ -0,0 +1,252 @@ +/** Bounded TypeSafe SystemOne transport. Only validated answers leave this boundary. */ +export type JevQuestion = + | { type: "noul"; instructions: string; criteria?: { true: string; false: string } } + | { type: "choice"; instructions: string; criteria: Record } + | { type: "score"; instructions: string; criteria: string[] }; + +export type JevAnswer = + | { type: "noul"; noul: number } + | { type: "choice"; choice: string; confidence: number; probabilities: Record } + | { + type: "score"; + score: number; + confidence: number; + legend: Record; + probabilities: Record; + }; + +export type JevRequest = { + state: object | string | unknown[]; + questions: Record; +}; +export type JevResult = { + model: string; + answers: Record; + usage: { input_tokens: number; output_tokens: number }; + latencyMs: number; +}; +export type JevOptions = { + apiKey?: string; + model?: string; + fetch?: (input: string, init?: RequestInit) => Promise; + timeoutMs?: number; + signal?: AbortSignal; +}; + +const ENDPOINT = "https://api.typesafe.ai/v1/systemone"; + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid Jev response"); + } + return value as Record; +} +function probability(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { + throw new Error("invalid Jev probability"); + } + return value; +} +function keysMatch(actual: Record, expected: readonly string[]): void { + let keys = Object.keys(actual); + if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) { + throw new Error("invalid Jev answer keys"); + } +} +function distribution(value: unknown, expected: readonly string[]): Record { + let raw = object(value); + keysMatch(raw, expected); + let result: Record = {}; + for (let key of expected) result[key] = probability(raw[key]); + let total = Object.values(result).reduce((sum, item) => sum + item, 0); + if (Math.abs(total - 1) > 0.08) throw new Error("invalid Jev distribution"); + return result; +} + +export function validateJevResponse( + value: unknown, + questions: Record, +): JevResult { + let response = object(value); + if (typeof response.model !== "string" || !response.model || response.model.length > 100) { + throw new Error("invalid Jev model"); + } + let usage = object(response.usage); + for (let count of [usage.input_tokens, usage.output_tokens]) { + if (!Number.isSafeInteger(count) || (count as number) < 0) throw new Error("invalid Jev usage"); + } + let rawAnswers = object(response.answers); + keysMatch(rawAnswers, Object.keys(questions)); + let answers: Record = {}; + for (let [id, question] of Object.entries(questions)) { + let raw = object(rawAnswers[id]); + if (raw.type !== question.type) throw new Error("invalid Jev answer type"); + if (question.type === "noul") { + keysMatch(raw, ["type", "noul"]); + answers[id] = { type: "noul", noul: probability(raw.noul) }; + } else if (question.type === "choice") { + keysMatch(raw, ["type", "choice", "confidence", "probabilities"]); + let options = Object.keys(question.criteria); + let probabilities = distribution(raw.probabilities, options); + if (typeof raw.choice !== "string" || !(raw.choice in question.criteria)) { + throw new Error("invalid Jev choice"); + } + if (probabilities[raw.choice] + 0.01 < Math.max(...Object.values(probabilities))) { + throw new Error("invalid Jev winning choice"); + } + answers[id] = { + type: "choice", + choice: raw.choice, + confidence: probability(raw.confidence), + probabilities, + }; + } else { + keysMatch(raw, ["type", "score", "confidence", "legend", "probabilities"]); + let levels = question.criteria.map((_, index) => String(index)); + let legend = object(raw.legend); + keysMatch(legend, levels); + for (let [index, label] of question.criteria.entries()) { + if (legend[String(index)] !== label) throw new Error("invalid Jev score legend"); + } + let probabilities = distribution(raw.probabilities, levels); + let weighted = levels.reduce((sum, key, index) => sum + index * probabilities[key], 0); + if ( + typeof raw.score !== "number" || !Number.isFinite(raw.score) + || raw.score < 0 || raw.score > levels.length - 1 + || Math.abs(raw.score - weighted) > 0.12 + ) throw new Error("invalid Jev score"); + answers[id] = { + type: "score", + score: raw.score, + confidence: probability(raw.confidence), + legend: legend as Record, + probabilities, + }; + } + } + return { + model: response.model, + answers, + usage: { + input_tokens: usage.input_tokens as number, + output_tokens: usage.output_tokens as number, + }, + latencyMs: 0, + }; +} + +function validateRequest(request: JevRequest): void { + let count = Object.keys(request.questions).length; + if (count < 1 || count > 45 || JSON.stringify(request.state).length > 24000) { + throw new Error("invalid Jev request bounds"); + } + for (let [id, question] of Object.entries(request.questions)) { + if ( + !id || id.length > 100 || !/^[\w:-]+$/.test(id) + || !question.instructions || question.instructions.length > 1000 + ) { + throw new Error("invalid Jev question"); + } + if (question.type === "choice") { + let options = Object.entries(question.criteria); + if ( + options.length < 2 || options.length > 255 + || options.some(([key, description]) => + !key || key.length > 200 || !description || description.length > 500 + ) + ) throw new Error("invalid Jev choice criteria"); + } else if (question.type === "score") { + if ( + question.criteria.length < 2 || question.criteria.length > 10 + || question.criteria.some((label) => !label || label.length > 500) + ) { + throw new Error("invalid Jev score criteria"); + } + } + } +} + +export async function askJev(request: JevRequest, options: JevOptions = {}): Promise { + validateRequest(request); + let apiKey = options.apiKey ?? process.env.JEV_API_KEY ?? process.env.TYPESAFE_API_KEY; + if (!apiKey) throw new Error("Jev API key is not configured"); + let model = options.model ?? "jev-latest"; + if (!model || model.length > 100 || !/^[A-Za-z0-9._-]+$/.test(model)) { + throw new Error("invalid Jev model selection"); + } + let timeoutMs = options.timeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000) { + throw new Error("invalid Jev timeout"); + } + let timeout = AbortSignal.timeout(timeoutMs); + let signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let abortListener!: () => void; + let aborted = new Promise((_, reject) => { + abortListener = () => reject(new Error("Jev request aborted")); + if (signal.aborted) abortListener(); + else signal.addEventListener("abort", abortListener, { once: true }); + }); + let transportError = () => + new Error( + options.signal?.aborted + ? "Jev request aborted" + : timeout.aborted + ? "Jev request timed out" + : "Jev request failed", + ); + let started = performance.now(); + try { + let response: Response; + try { + response = await Promise.race([ + (options.fetch ?? fetch)(ENDPOINT, { + method: "POST", + redirect: "error", + signal, + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model, ...request }), + }), + aborted, + ]); + } catch { + throw transportError(); + } + if (!response.ok) throw new Error(`Jev HTTP ${response.status}`); + let size = Number(response.headers.get("content-length")); + if (Number.isFinite(size) && size > 262144) throw new Error("Jev response is too large"); + let reader = response.body?.getReader(); + let decoder = new TextDecoder(); + let body = ""; + let bytes = 0; + if (reader) { + while (true) { + let chunk: Awaited>; + try { + chunk = await Promise.race([reader.read(), aborted]); + } catch { + void reader.cancel().catch(() => {}); + throw transportError(); + } + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 262144) { + void reader.cancel().catch(() => {}); + throw new Error("Jev response is too large"); + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error("invalid Jev JSON"); + } + let result = validateJevResponse(parsed, request.questions); + result.latencyMs = Math.round(performance.now() - started); + return result; + } finally { + signal.removeEventListener("abort", abortListener); + } +} diff --git a/apps/server/src/conversation-plan/job-context.test.ts b/apps/server/src/conversation-plan/job-context.test.ts new file mode 100644 index 00000000..e331d6bc --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.test.ts @@ -0,0 +1,326 @@ +import { expect, test } from "bun:test"; + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; +import { createJobContexts } from "./job-context"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import { effectsFor } from "./effects"; + +function plan(owner?: string): Parameters["remember"]>[0] { + return { + chat: { owner: owner ? { sessionId: owner } : undefined }, + conversationPlan: initialState(), + conversationPlanJobs: [], + conversationPlanPendingEffects: [], + pendingCardActions: [], + persistence: { committedSidecar: {} }, + }; +} + +function context(sessionId: string): Chat.Room { + return { claimantSessionId: sessionId } as Chat.Room; +} + +function job(trigger: string): Job { + return { + id: `refine:W1:${trigger}`, + kind: "refine", + target: "W1", + trigger, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("a job claims its original member after more than 64 later messages", () => { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [job("m0")]; + let original = context("original-session"); + contexts.remember(opened, "m0", original); + for (let index = 1; index <= 80; index++) { + contexts.remember(opened, `m${index}`, context(`other-${index}`)); + } + expect(contexts.claim(opened, job("m0"))).toEqual({ + context: original, + claimantSessionId: "original-session", + }); + expect(contexts.claim(opened, job("missing"))).toBeUndefined(); +}); + +test("a failed accept removes only its own captured context", () => { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [job("old-message")]; + let old = context("old"); + let failed = context("failed"); + contexts.remember(opened, "old-message", old); + contexts.remember(opened, "new-message", failed); + contexts.forget(opened, "new-message", failed); + expect(contexts.claim(opened, job("new-message"))).toBeUndefined(); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(old); + let replacement = context("replacement"); + let prior = contexts.remember(opened, "old-message", replacement); + contexts.forget(opened, "old-message", old); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(replacement); + contexts.restore(opened, "old-message", replacement, prior); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(old); +}); + +test("a duplicate-ID accept failure restores the first accepted claimant", async () => { + let contexts = createJobContexts(); + let opened = plan(); + let accepted = context("accepted-session"); + let duplicate = context("duplicate-session"); + await contexts.accept(opened, "same-message", accepted, async () => { + opened.conversationPlanJobs = [job("same-message")]; + }); + await expect(contexts.accept(opened, "same-message", duplicate, async () => { + throw new Error("duplicate message"); + })).rejects.toThrow("duplicate message"); + expect(contexts.claim(opened, job("same-message"))).toEqual({ + context: accepted, + claimantSessionId: "accepted-session", + }); +}); + +test("current owner wins credentials without borrowing a later member's session", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + opened.conversationPlanJobs = [job("m1")]; + let original = context("original"); + contexts.remember(opened, "m1", original); + contexts.remember(opened, "m2", context("later")); + expect(contexts.claim(opened, job("m1"))).toEqual({ + context: original, + claimantSessionId: "owner", + }); + expect(contexts.claim(opened, job("missing"))).toBeUndefined(); + let owner = context("owner"); + contexts.remember(opened, "owner-message", owner); + expect(contexts.claim(opened, job("missing"))).toEqual({ + context: owner, + claimantSessionId: "owner", + }); +}); + +test("a prose job requires its winning Save claimant even when another owner context exists", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + contexts.remember(opened, "old-message", context("owner")); + let prose: Job = { + ...job("decided:W1:2"), + id: "prose:W1:decided:W1:2", + kind: "prose", + }; + expect(contexts.claim(opened, prose)).toBeUndefined(); + opened.conversationPlanJobs = [prose]; + let saver = context("save-session"); + contexts.remember(opened, prose.trigger, saver); + expect(contexts.claim(opened, prose)).toEqual({ + context: saver, + claimantSessionId: "owner", + }); +}); + +test("accepted messages without unfinished work release their original job contexts", async () => { + let contexts = createJobContexts(); + let opened = plan(); + for (let index = 0; index < 1000; index++) { + await contexts.accept(opened, `m${index}`, context(`member-${index}`), async () => {}); + } + expect(contexts.claim(opened, job("m0"))).toBeUndefined(); + expect(contexts.claim(opened, job("m998"))).toBeUndefined(); + expect(contexts.any(opened)?.claimantSessionId).toBe("member-999"); +}); + +test("completed and skipped jobs release contexts while retryable jobs retain them", () => { + for (let status of ["pending", "running", "failed", "done", "skipped"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [{ ...job("original"), status }]; + let original = context("original-member"); + contexts.remember(opened, "original", original); + for (let index = 0; index < 80; index++) { + contexts.remember(opened, `later-${index}`, context("later")); + } + expect(contexts.claim(opened, job("original"))?.context).toBe( + status === "done" || status === "skipped" ? undefined : original, + ); + opened.conversationPlanJobs = []; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("queued and failed analysis retain their member until the queue entry is consumed", () => { + for (let status of ["pending", "processing", "failed"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlan.queue = [{ messageId: "original", status, attempts: 1 }]; + let original = context("original-member"); + contexts.remember(opened, "original", original); + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, job("original"))?.context).toBe(original); + opened.conversationPlan.queue = []; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("winning Save context survives the pending action and job-effect handover", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + let trigger = "decided:W1:2"; + let prose: Job = { ...job(trigger), kind: "prose" }; + let saver = context("saver"); + opened.pendingCardActions = [{ + id: "card:W1:decided:2", + kind: "decided", + cardId: "W1", + threadId: "t1", + actor: "saver", + at: 1, + generation: 2, + optionIds: [], + text: "Use Alpha", + }]; + contexts.remember(opened, trigger, saver); + contexts.remember(opened, "owner", context("owner")); + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.pendingCardActions = []; + opened.conversationPlanPendingEffects = [{ + key: "prose-effect", + kind: "job", + threadId: "t1", + intent: { kind: "prose", target: "W1", trigger }, + }]; + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.conversationPlanPendingEffects = []; + opened.conversationPlanJobs = [prose]; + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.conversationPlanJobs = [{ ...prose, status: "done" }]; + expect(contexts.claim(opened, prose)).toBeUndefined(); +}); + +test("the current owner fallback survives later members and replaces older owner contexts", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + contexts.remember(opened, "old-owner", context("owner")); + let current = context("owner"); + contexts.remember(opened, "current-owner", current); + contexts.remember(opened, "later-member", context("later")); + expect(contexts.claim(opened, job("missing"))?.context).toBe(current); + opened.chat.owner = undefined; + expect(contexts.claim(opened, job("current-owner"))).toBeUndefined(); + expect(contexts.any(opened)?.claimantSessionId).toBe("later"); +}); + +test("acceptance retains its claimant while another message commits before its queue entry", async () => { + let contexts = createJobContexts(); + let opened = plan(); + let original = context("original"); + let release!: () => void; + let waiting = new Promise(resolve => { + release = resolve; + }); + let accepting = contexts.accept(opened, "first", original, async () => { + await waiting; + opened.conversationPlan.queue = [{ messageId: "first", status: "pending", attempts: 0 }]; + }); + await contexts.accept(opened, "second", context("second"), async () => {}); + expect(contexts.claim(opened, job("first"))?.context).toBe(original); + release(); + await accepting; + expect(contexts.claim(opened, job("first"))?.context).toBe(original); + opened.conversationPlan.queue = []; + expect(contexts.claim(opened, job("first"))).toBeUndefined(); +}); + +test("pruning preserves committed roots while a removal is staged and can roll back", () => { + for (let root of ["analysis", "job", "effect"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + if (root === "analysis") { + opened.conversationPlan.queue = [{ + messageId: "original", + status: "processing", + attempts: 1, + }]; + } else if (root === "job") { + opened.conversationPlanJobs = [job("original")]; + } else { + opened.conversationPlanPendingEffects = [{ + key: "refine-effect", + kind: "job", + intent: { kind: "refine", target: "W1", trigger: "original" }, + }]; + } + let committed = { + conversationPlan: opened.conversationPlan, + conversationPlanJobs: opened.conversationPlanJobs, + conversationPlanPendingEffects: opened.conversationPlanPendingEffects, + pendingCardActions: [], + }; + opened.persistence.committedSidecar = JSON.parse(JSON.stringify(committed)); + let original = context("original"); + contexts.remember(opened, "original", original); + opened.conversationPlan = initialState(); + opened.conversationPlanJobs = []; + opened.conversationPlanPendingEffects = []; + contexts.remember(opened, "later", context("later")); + Object.assign(opened, committed); + expect(contexts.claim(opened, job("original"))?.context).toBe(original); + opened.conversationPlan = initialState(); + opened.conversationPlanJobs = []; + opened.conversationPlanPendingEffects = []; + opened.persistence.committedSidecar = {}; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("card insertion keeps the original question claimant until linking creates its refine effect", () => { + let contexts = createJobContexts(); + let opened = plan(); + let source = { + messageId: "question-message", + author: { kind: "member", handle: "alice" } as const, + quote: "Which editor?", + start: 0, + end: 13, + role: "question" as const, + }; + let event = { + id: "opened-event", + type: "thread.opened" as const, + threadId: "t1", + observedThreadVersion: 0, + origin: "classifier" as const, + actor: { kind: "classifier" } as const, + at: 1, + question: source.quote, + source, + }; + opened.conversationPlan = applyEvent(opened.conversationPlan, event); + opened.conversationPlanPendingEffects = effectsFor([event], opened.conversationPlan); + let original = context("question-author"); + contexts.remember(opened, source.messageId, original); + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, job(source.messageId))?.context).toBe(original); + let linked = { + id: "linked-event", + type: "card.linked" as const, + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier" as const, + actor: { kind: "classifier" } as const, + at: 2, + questionnaireId: "W1", + }; + opened.conversationPlan = applyEvent(opened.conversationPlan, linked); + opened.conversationPlanPendingEffects = effectsFor([linked], opened.conversationPlan); + expect(contexts.claim(opened, job(source.messageId))?.context).toBe(original); + opened.conversationPlanPendingEffects = []; + expect(contexts.claim(opened, job(source.messageId))).toBeUndefined(); +}); diff --git a/apps/server/src/conversation-plan/job-context.ts b/apps/server/src/conversation-plan/job-context.ts new file mode 100644 index 00000000..b7515af5 --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.ts @@ -0,0 +1,162 @@ +/** + * Process-local Chat contexts for the member message that started a job. + * Retain claimants while durable work can still use them, plus the current + * owner and latest authenticated context for jobs without a captured trigger. + */ + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; +import type { Plan as OpenPlan } from "../plan/service"; +import { proseJobTrigger } from "../questions/card-actions"; + +type Work = Pick< + OpenPlan, + | "conversationPlan" + | "conversationPlanJobs" + | "conversationPlanPendingEffects" + | "pendingCardActions" +>; +type Plan = Work & { + chat: { owner?: { sessionId: string } }; + persistence: Pick; +}; + +export function createJobContexts() { + let byPlan = new WeakMap>(); + let accepting = new WeakMap>(); + + function prune(plan: Plan): void { + let byMessage = byPlan.get(plan); + if (!byMessage) return; + let needed = new Set(accepting.get(plan)?.keys()); + function retain(work: Partial): void { + for (let item of work.conversationPlan?.queue ?? []) needed.add(item.messageId); + for (let job of work.conversationPlanJobs ?? []) { + if (job.status === "pending" || job.status === "running" || job.status === "failed") { + needed.add(job.trigger); + } + } + for (let action of work.pendingCardActions ?? []) { + if (action.kind === "decided") { + needed.add(proseJobTrigger(action.cardId, action.generation)); + } + } + for (let effect of work.conversationPlanPendingEffects ?? []) { + if (effect.kind === "job") needed.add(effect.intent.trigger); + if ("source" in effect && effect.source) needed.add(effect.source.messageId); + if ("sources" in effect) { + for (let source of effect.sources) needed.add(source.messageId); + } + if ("messageId" in effect) needed.add(effect.messageId); + if ("messageIds" in effect) { + for (let id of effect.messageIds) needed.add(id); + } + if (effect.kind === "prompt") { + for (let id of effect.sourceMessageIds ?? []) needed.add(id); + } + if (effect.kind === "insert-card") { + // Linking the card creates a refine job from the original question source. + let thread = work.conversationPlan?.threads.find(item => item.id === effect.threadId); + let source = thread?.questionSources[0]; + if (source) needed.add(source.messageId); + } + } + } + retain(plan); + // Live fields can remove work before its fenced commit; keep the rollback roots too. + let committed = plan.persistence.committedSidecar; + if (committed && typeof committed === "object" && !Array.isArray(committed)) { + retain(committed as Partial); + } + let entries = [...byMessage]; + let latest = entries.at(-1); + let owner = entries.findLast(([, context]) => + context.claimantSessionId === plan.chat.owner?.sessionId + ); + if (latest) needed.add(latest[0]); + if (owner) needed.add(owner[0]); + for (let id of byMessage.keys()) { + if (!needed.has(id)) byMessage.delete(id); + } + } + + return { + remember(plan: Plan, messageId: string, context: Chat.Room): Chat.Room | undefined { + let byMessage = byPlan.get(plan) ?? new Map(); + byPlan.set(plan, byMessage); + let previous = byMessage.get(messageId); + byMessage.delete(messageId); + byMessage.set(messageId, context); + prune(plan); + return previous; + }, + async accept( + plan: Plan, + messageId: string, + context: Chat.Room, + commit: () => Promise, + ): Promise { + let byMessage = byPlan.get(plan) ?? new Map(); + byPlan.set(plan, byMessage); + let previous = byMessage.get(messageId); + let pending = accepting.get(plan) ?? new Map(); + accepting.set(plan, pending); + pending.set(messageId, (pending.get(messageId) ?? 0) + 1); + byMessage.delete(messageId); + byMessage.set(messageId, context); + prune(plan); + try { + return await commit(); + } catch (error) { + if (byMessage.get(messageId) === context) { + if (previous) byMessage.set(messageId, previous); + else byMessage.delete(messageId); + } + throw error; + } finally { + let count = pending.get(messageId)! - 1; + if (count) pending.set(messageId, count); + else pending.delete(messageId); + prune(plan); + } + }, + forget(plan: Plan, messageId: string, context: Chat.Room): void { + let byMessage = byPlan.get(plan); + if (byMessage?.get(messageId) === context) byMessage.delete(messageId); + }, + restore( + plan: Plan, + messageId: string, + context: Chat.Room, + previous?: Chat.Room, + ): void { + let byMessage = byPlan.get(plan); + if (byMessage?.get(messageId) !== context) return; + if (previous) byMessage.set(messageId, previous); + else byMessage.delete(messageId); + }, + clear(plan: Plan): void { + byPlan.delete(plan); + accepting.delete(plan); + }, + any(plan: Plan): Chat.Room | undefined { + prune(plan); + return [...(byPlan.get(plan)?.values() ?? [])].at(-1); + }, + claim(plan: Plan, job: Job): { context: Chat.Room; claimantSessionId: string } | undefined { + prune(plan); + let byMessage = byPlan.get(plan); + let triggered = byMessage?.get(job.trigger); + let owner = plan.chat.owner?.sessionId; + if (job.kind === "prose" && !triggered) return undefined; + if (owner) { + let context = triggered + ?? [...(byMessage?.values() ?? [])].findLast(item => item.claimantSessionId === owner); + return context ? { context, claimantSessionId: owner } : undefined; + } + return triggered + ? { context: triggered, claimantSessionId: triggered.claimantSessionId } + : undefined; + }, + }; +} diff --git a/apps/server/src/conversation-plan/job-prompts-sources.test.ts b/apps/server/src/conversation-plan/job-prompts-sources.test.ts new file mode 100644 index 00000000..1a74afc5 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts-sources.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; + +import { refinePrompt } from "./job-prompts"; + +test("a question mention exposes its exact source without treating it as support", () => { + let quote = "Lexical or ProseMirror 🤖?"; + let ref = { + messageId: "m1", + author: { kind: "member", handle: "mina" }, + quote, + start: 12, + end: 12 + quote.length, + role: "question", + }; + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which editor?", + options: [], + outline: "", + revision: 1, + thread: { questionSources: [ref], contributions: [], stances: [] } as never, + }); + let line = prompt.split("\n").find(line => line.startsWith("- question_source=")); + expect(JSON.parse(line!.slice("- question_source=".length))).toEqual(ref); + expect(prompt).toContain("it does not\nendorse either choice"); + expect(prompt).toContain( + "Keep a question-sourced\noption label faithful to the named alternative", + ); + expect(prompt).toContain("do not add specificity that the\nquestion did not name"); +}); + +test("source evidence is bounded by complete citations, never a sliced JSON span", () => { + let sources = Array.from({ length: 20 }, (_, index) => ({ + messageId: `m${index}`, + author: { kind: "member", handle: "fawkes" }, + quote: `Option ${index} ${"a".repeat(700)}`, + start: 0, + end: 708 + String(index).length, + role: "option", + })); + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: sources.map((source, index) => ({ + kind: "option", + text: `Option ${index}`, + sources: [source], + })), + stances: [], + } as never, + }); + let evidence = prompt.split("What people said:\n")[1]!.split("\n\n")[0]!; + expect(evidence.length).toBeLessThanOrEqual(6000); + expect(evidence).toContain('"messageId":"m19"'); + for (let line of evidence.split("\n")) { + let cited = JSON.parse(line.slice(line.indexOf("source=") + "source=".length)); + expect(cited.quote).toBe(sources[Number(cited.messageId.slice(1))]?.quote); + } +}); + +test("unsourced evidence does not acquire a fabricated citation", () => { + let prompt = refinePrompt({ + kind: "suggest", + id: "W1", + question: "Which storage approach?", + options: [], + outline: "0 paragraph sha256:123 PostgreSQL is installed", + revision: 1, + thread: { + contributions: [{ kind: "reason", text: "Repository uses PostgreSQL", sources: [] }], + stances: [], + } as never, + }); + expect(prompt).toContain("- reason: Repository uses PostgreSQL"); + expect(prompt).not.toContain("source={"); + expect(prompt).toContain("For repository/document evidence, omit"); +}); + +test("reason and constraint citations cannot be copied as option provenance", () => { + let sources = ["reason", "constraint"].map((role, index) => ({ + messageId: `m${index}`, + author: { kind: "member", handle: "mina" }, + quote: role === "reason" ? "Needs keyboard support" : "Avoid a new dependency", + start: 0, + end: role === "reason" ? 22 : 22, + role, + })); + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: sources.map(source => ({ + kind: source.role, + text: source.quote, + sources: [source], + })), + stances: [], + } as never, + }); + expect(prompt).toContain("context_source="); + expect(prompt).not.toContain("option_source="); + expect(prompt).toContain("Never copy a"); + expect(prompt).toContain("Those may inform"); +}); diff --git a/apps/server/src/conversation-plan/job-prompts.test.ts b/apps/server/src/conversation-plan/job-prompts.test.ts new file mode 100644 index 00000000..844eb819 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; + +import { headingPrompt, refinePrompt } from "./job-prompts"; + +test("the heading prompt quotes recent members and names its one writing tool", () => { + let prompt = headingPrompt({ + transcript: [ + { + id: "m1", + author: { kind: "member", handle: "mina" }, + text: "Okay we gotta figure out auth", + ts: 1, + }, + { id: "m2", author: { kind: "system" }, text: "Sam joined", ts: 2 }, + ], + outline: "", + }); + expect(prompt).toContain("- @mina: Okay we gotta figure out auth"); + expect(prompt).not.toContain("Sam joined"); + expect(prompt).toContain("call `draft_heading` once"); + expect(prompt).toContain("Use no other writing tool. Do not reply in chat."); +}); + +test("a suggest prompt forbids retitling and moving", () => { + let prompt = refinePrompt({ + kind: "suggest", + id: "W1", + question: "What auth system should we use?", + options: ["Auth0", "GitHub Apps"], + outline: "0 heading sha256:123", + revision: 3, + }); + expect(prompt).toContain("1. Auth0\n2. GitHub Apps"); + expect(prompt).toContain("Do not change the title and do not move the card."); + expect(prompt).toContain("call `refine_decision` exactly once with id W1"); +}); + +test("a refine prompt includes bounded evidence and card option labels", () => { + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Where should auth live?", + options: ["Auth0", "GitHub Apps"], + outline: "0 heading sha256:123", + revision: 3, + thread: { + contributions: [{ + kind: "reason", + text: `Use the repository identity ${"x".repeat(2000)}`, + sources: [{ + messageId: "m1", + quote: "Use the repository identity", + start: 0, + end: 27, + role: "reason", + author: { + kind: "member", + handle: "mina", + }, + }], + }], + stances: [{ participant: "sam", position: "support", optionId: "o2", sources: [] }], + } as never, + }); + expect(prompt).toContain("What people said:"); + expect(prompt).toContain( + 'context_source={"messageId":"m1","author":{"kind":"member","handle":"mina"},', + ); + expect(prompt).toContain('"quote":"Use the repository identity","start":0,"end":27,'); + expect(prompt).toContain("- @sam supports o2"); + expect(prompt).toContain("Reword the title to ask which approach the team should choose"); + expect(prompt).toContain("Split mutually exclusive alternatives"); + expect(prompt).toContain("including slash lists"); + expect(prompt).toContain("Compare the underlying approaches, not just exact labels"); + expect(prompt).toContain("Do not invent a source or rationale"); + expect(prompt).toContain( + "Only a displayed `option_source` with role `option` or a `question_source`", + ); + expect(prompt).toContain("`context_source` from a reason, constraint, or stance"); + expect(prompt).toContain("For repository/document evidence, omit"); + expect(prompt).toContain("unclear, omit that addition"); + expect(prompt).toContain("place_after"); + expect(prompt).not.toContain("x".repeat(1000)); +}); + +test("a refine prompt preserves exact UTF-16 source offsets and whitespace", () => { + let span = "Tiptap or\nProseMirror 🤖"; + let source = { + messageId: "m-emoji", + author: { kind: "member", handle: "fawkes" }, + quote: span, + start: 7, + end: 7 + span.length, + role: "option", + }; + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach should we use for highlighting?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: [{ kind: "option", text: span, sources: [source] }], + stances: [], + } as never, + }); + let line = prompt.split("\n").find(line => line.startsWith("- option:")); + expect(line).toBeDefined(); + expect(line).toContain("option_source="); + let cited = JSON.parse(line!.slice(line!.indexOf("source=") + "source=".length)); + expect(cited).toEqual(source); + expect(cited.end - cited.start).toBe(span.length); + expect(prompt).toContain("omit `source` when neither exact source is shown"); +}); diff --git a/apps/server/src/conversation-plan/job-prompts.ts b/apps/server/src/conversation-plan/job-prompts.ts new file mode 100644 index 00000000..4858b3c4 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts.ts @@ -0,0 +1,186 @@ +/** Fixed, bounded instructions for one background Planner job. */ + +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import * as edit from "../plan/edit"; +import { proseInput, prosePrompt } from "./prose-job"; +import type { Plan } from "../plan/service"; + +function quote(value: string, max = 500): string { + return value.replace(/\s+/g, " ").trim().slice(0, max); +} + +function source(ref: ConversationPlan.SourceRef | undefined): string { + if (!ref) return ""; + let label = ref.role === "option" ? "option_source" : ref.role === "question" + ? "question_source" + : "context_source"; + return ` ${label}=${ + JSON.stringify({ + messageId: ref.messageId, + author: ref.author, + quote: ref.quote, + start: ref.start, + end: ref.end, + role: ref.role, + }) + }`; +} + +function boundedLines(lines: string[], max: number): string { + let kept: string[] = []; + let used = 0; + for (let index = lines.length - 1; index >= 0; index--) { + let line = lines[index]!; + let length = line.length + (kept.length ? 1 : 0); + if (used + length > max) continue; + kept.unshift(line); + used += length; + } + return kept.join("\n"); +} + +function outline(plan: Plan): string { + return edit.outline(plan).slice(0, 50) + .map(block => `${block.index} ${block.type} ${block.digest} ${quote(block.preview, 120)}`) + .join("\n"); +} + +export function headingPrompt(input: { transcript: Chat.Entry[]; outline: string }): string { + let said = input.transcript + .filter(entry => entry.author.kind === "member") + .slice(-15) + .map(entry => + `- @${quote((entry.author as Extract).handle, 80)}: ${ + quote(entry.text) + }` + ) + .join("\n"); + return [ + "[Background job: heading]", + "People have started discussing a new, empty document. Give it a title and a one-sentence goal.", + "", + "Recent conversation:", + said || "(none)", + "", + "Current document outline:", + input.outline.slice(0, 6000) || "(empty)", + "", + "Call `read_plan`, then call `draft_heading` once with its revision, a short title (at most", + "80 characters, no `#`) and a goal of one sentence in the team's own terms, starting", + '"Goal:". Describe what they are trying to decide, not a decision they have not made.', + "Use no other writing tool. Do not reply in chat.", + ].join("\n"); +} + +export function refinePrompt(input: { + kind: "refine" | "suggest"; + id: string; + question: string; + options: string[]; + thread?: ConversationPlan.Thread; + outline: string; + revision: number; +}): string { + let { kind, thread } = input; + let labels = new Map( + thread?.contributions.filter(item => item.kind === "option") + .map(item => [item.id, quote(item.text, 200)] as const) ?? [], + ); + let evidence = boundedLines( + thread?.contributions.slice(-20).map(item => + `- ${item.kind}: ${quote(item.text)}${source(item.sources[0])}` + ) ?? [], + 6000, + ); + let questionSources = boundedLines( + thread?.questionSources?.slice(-20).map(ref => `- ${source(ref).trim()}`) ?? [], + 4000, + ); + let stances = boundedLines( + thread?.stances.slice(-20).map(stance => + `- @${quote(stance.participant, 80)} ${stance.position}s ${ + labels.get(stance.optionId ?? "") ?? quote(stance.optionId ?? "the question", 200) + }${source(stance.sources[0])}` + ) ?? [], + 4000, + ); + let task = kind === "refine" + ? [ + "1. Reword the title to ask which approach the team should choose for the actual task,", + " not which options exist or are installed. Keep the meaning, use at most 80 characters,", + ' and end with "?".', + "2. Add options only when the discussion, the repository or the document justifies them.", + "3. If the card belongs after a particular block of prose, give `place_after` with that", + " block's index and digest from `read_plan`. Otherwise leave it where it is.", + ] + : [ + "People added options in chat. Consider whether an obvious, sensible option is still", + "missing. Do not change the title and do not move the card.", + ]; + return [ + `[Background job: ${kind}]`, + `Decision card ${quote(input.id, 200)}: "${quote(input.question)}"`, + "", + "Options so far:", + input.options.slice(0, 10).map((label, index) => `${index + 1}. ${quote(label, 200)}`) + .join("\n") || "(none)", + "", + ...(questionSources ? ["Question mentions:", questionSources, ""] : []), + ...(evidence ? ["What people said:", evidence, ""] : []), + ...(stances ? ["Positions:", stances, ""] : []), + "Document outline:", + input.outline.slice(0, 6000) || "(empty)", + "", + ...task, + "", + "Each option must name one actionable approach. Split mutually exclusive alternatives", + "mentioned together (such as A or B or C, including slash lists) into separate options.", + "Keep related APIs that", + "form one approach together. Compare the underlying approaches, not just exact labels,", + "against existing options and each other; do not add a reworded or grouped duplicate.", + "For each addition, write a one-sentence rationale tied to a specific conversation source", + "above or to identifiable document/repository evidence. If the evidence or the split is", + "unclear, omit that addition. Do not invent a source or rationale.", + "Only a displayed `option_source` with role `option` or a `question_source` with role", + "`question` may be copied unchanged into `add_options[].source`, including UTF-16", + "start/end and exact quote. A question mention names possibilities; it does not", + "endorse either choice. Use it as provenance only when the quote names the option", + "with the option's own terms, not as evidence of support. Keep a question-sourced", + "option label faithful to the named alternative; do not add specificity that the", + "question did not name. Never copy a", + "`context_source` from a reason, constraint, or stance into that field. Those may inform", + "the rationale, but omit `source` when neither exact source is shown. Do not derive", + "offsets or quotes from the shortened summary. For repository/document evidence, omit", + "`source`.", + "The card holds at most 10 options. Adding nothing is a good result when nothing is missing.", + `Call \`read_plan\`, then call \`refine_decision\` exactly once with id ${ + quote(input.id, 200) + }.`, + "Use no other writing tool. Do not reply in chat.", + ].join("\n"); +} + +function card(plan: Plan, job: ConversationPlan.Job, kind: "refine" | "suggest"): string { + let record = plan.records.get(job.target); + let question = record?.definition.questions[0]; + let thread = plan.conversationPlan.threads.find(item => item.questionnaireId === job.target); + return refinePrompt({ + kind, + id: job.target, + question: question?.question ?? "", + options: question?.options.map(option => option.label) ?? [], + thread, + outline: outline(plan), + revision: plan.revision, + }); +} + +export const PROMPT_FOR: Record< + ConversationPlan.JobKind, + ((plan: Plan, job: ConversationPlan.Job) => string) | undefined +> = { + heading: plan => headingPrompt({ transcript: plan.chat.entries, outline: outline(plan) }), + refine: (plan, job) => card(plan, job, "refine"), + suggest: (plan, job) => card(plan, job, "suggest"), + prose: (plan, job) => prosePrompt(proseInput(plan, job.target)), +}; diff --git a/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts b/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts new file mode 100644 index 00000000..a7287092 --- /dev/null +++ b/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts @@ -0,0 +1,172 @@ +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Jobs from "./jobs"; +import { authenticatedMemory } from "../chat/job-authenticated-memory.test-fixtures"; +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { createPlannerJobs } from "./planner-jobs"; +import { createScriptedHarness } from "./scripted-harness.test-driver"; +import { PROMPT_FOR } from "./job-prompts"; +import type { openPlan } from "../testing/plan"; + +type Opened = Awaited>; + +export async function scriptedRefine( + opened: Opened, + dir: string, + id: string, + authentication?: Awaited>, +) { + let plan = opened.plan; + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if (!state || typeof state !== "object" || Array.isArray(state)) { + throw new Error("missing durable sidecar"); + } + if (!Array.isArray(state.questions) || !Array.isArray(state.transcript)) { + throw new Error("missing durable card or transcript"); + } + Jobs.restore(state.conversationPlanJobs); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === id), + jobs: structuredClone(state.conversationPlanJobs ?? []) as typeof plan.conversationPlanJobs, + transcript: state.transcript, + }; + } + let latest = await saved(); + let commits: Array = []; + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let result = await commit(input); + latest = await saved(); + commits.push(latest); + return result; + }; + let publications: Array<{ frame: Record; saved: typeof latest }> = []; + let publish = opened.server.publish; + opened.server.publish = (...args) => { + publications.push({ + frame: JSON.parse(String(args[1])), + saved: structuredClone(latest), + }); + return publish(...args); + }; + let identity = authentication ?? await authenticatedMemory(opened, "refine"); + let owners = new ActiveOwnerBindings(identity.context.auth); + let context: Chat.Room = { + ...identity.context, + plan, + chat: plan.chat, + persist: () => Plan.persist(plan), + activeOwner: () => owners.resolve(opened.channel.id), + }; + let resultSnapshots: Array< + Promise<{ output: unknown; saved: Awaited> }> + > = []; + let driver = createScriptedHarness(dir, plan, { + onResult: async result => { + let snapshot = saved().then(saved => ({ output: result.output, saved })); + resultSnapshots.push(snapshot); + await snapshot; + }, + }); + let agent = createPlannerAgent(driver.fake, "refine"); + let sessionId: string | undefined; + let setups = 0; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + refineAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run(options: { + command: string; + env?: Record; + abortSignal?: AbortSignal; + }) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 + && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups++; + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let errors: unknown[] = []; + let jobs = createPlannerJobs({ + plan, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + runner: (job, prompt, signal) => Chat.job(context, job, prompt, identity.sessionId, signal), + prompt: job => PROMPT_FOR[job.kind]?.(plan, job), + publishJobs: current => + opened.server.publish( + opened.channel.id, + JSON.stringify({ + kind: "conversation-plan:jobs", + jobs: current, + }), + ), + publishMeta: target => Questions.announce(plan, opened.server, opened.channel.id, target), + activity: async text => { + await Chat.noticeExclusive(context, text); + }, + onError: error => errors.push(error), + }); + let closing: Promise | undefined; + return { + plan, + jobs, + driver, + identity, + errors, + saved, + results: () => Promise.all(resultSnapshots), + publications, + commits, + counts: () => ({ setups, unexpectedCommands, sandboxDestroys, credentialReleases }), + close() { + return closing ??= (async () => { + jobs.stop(); + owners.revokeAll(); + identity.revokeAll(); + await jobs.idle(); + await plan.chat.running; + try { + await Promise.all(resultSnapshots); + await Plan.close(plan); + } finally { + opened.storage.collaboration.commit = commit; + opened.server.publish = publish; + } + })(); + }, + }; +} diff --git a/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts b/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts new file mode 100644 index 00000000..fe593491 --- /dev/null +++ b/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { scriptedRefine } from "./job-scripted-refine-retry.test-fixtures"; + +const TITLE = "Which authentication approach should the pilot use?"; +const LABEL = "GitHub Apps"; +const RATIONALE = "It keeps the pilot small and concrete."; + +function script(id: string) { + return [{ + tool: "refine_decision", + args: { + revision: "$revision", + id, + title: TITLE, + add_options: [{ label: LABEL, rationale: RATIONALE }], + }, + }]; +} + +test("a failed scripted refine survives reopen and only an explicit corrected-file retry runs it", async () => { + let dir = await mkdtemp(join(tmpdir(), "scripted-refine-retry-")); + let opened = await openPlan("Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let current: Awaited> | undefined; + try { + let id = await Questions.insertConversationCard(plan, opened.server, opened.channel.id, { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options: [], + }); + plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "We need to choose authentication.", + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + let baseline = structuredClone(plan.records.get(id)!); + let source = Plan.source(plan); + let revision = plan.revision; + let draft = Store.get(plan.questions, id)!; + let draftRevision = draft.revision; + await writeFile(join(dir, "refine.json"), JSON.stringify(script("not-this-card"))); + current = await scriptedRefine({ ...opened, plan }, dir, id); + let first = current; + expect(first.driver.starts()).toBe(0); + expect(first.counts()).toEqual({ + setups: 0, + unexpectedCommands: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + await first.jobs.enqueue({ kind: "refine", target: id, trigger: "m1" }); + await first.jobs.idle(); + let failed = structuredClone(plan.conversationPlanJobs[0]!); + expect(failed).toMatchObject({ + id: `refine:${id}:m1`, + target: id, + trigger: "m1", + status: "failed", + attempts: 1, + reason: "The Planner ended without calling refine_decision.", + }); + expect(first.driver.results).toMatchObject([{ + toolName: "refine_decision", + output: "Error: This job refines a different decision.", + }]); + expect(first.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(first.driver.starts()).toBe(1); + expect(first.driver.destroyed()).toBe(1); + expect(first.identity.driver.starts()).toBe(0); + expect(plan.records.get(id)).toEqual(baseline); + expect(Plan.source(plan)).toBe(source); + expect(plan.revision).toBe(revision); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + let failureResult = (await first.results())[0]!; + expect(failureResult.saved.record).toEqual(baseline); + expect(failureResult.saved.source).toBe(source); + expect(failureResult.saved.transcript).toHaveLength(1); + let failedPublication = first.publications.find(({ frame }) => + frame.kind === "conversation-plan:jobs" + && (frame.jobs as Array<{ status: string }>)[0]?.status === "failed" + )!; + expect(failedPublication.saved.jobs).toEqual([failed]); + expect(failedPublication.saved.record).toEqual(baseline); + expect(failedPublication.saved.transcript).toHaveLength(1); + expect(first.errors).toEqual([]); + expect(first.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + await first.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + current = undefined; + expect(plan.conversationPlanJobs).toEqual([failed]); + expect(plan.records.get(id)).toEqual(baseline); + expect(Plan.source(plan)).toBe(source); + current = await scriptedRefine({ ...opened, plan }, dir, id, first.identity); + let retry = current; + retry.jobs.wake(); + await retry.jobs.idle(); + expect(retry.driver.starts()).toBe(0); + expect(retry.driver.destroyed()).toBe(0); + expect(plan.conversationPlanJobs).toEqual([failed]); + await writeFile(join(dir, "refine.json"), JSON.stringify(script("$target"))); + expect(await retry.jobs.retry(failed.id)).toBe(true); + await retry.jobs.idle(); + let done = plan.conversationPlanJobs[0]!; + expect(done).toMatchObject({ + id: failed.id, + kind: failed.kind, + target: failed.target, + trigger: failed.trigger, + status: "done", + attempts: 2, + }); + expect(done.reason).toBeUndefined(); + expect(JSON.parse(done.output!)).toEqual({ title: TITLE, added: 1 }); + let record = structuredClone(plan.records.get(id)!); + let question = record.definition.questions[0]!; + expect(question.question).toBe(TITLE); + expect(question.options).toHaveLength(1); + let option = question.options[0]!; + expect(option.id).toBeTruthy(); + expect(option.label).toBe(LABEL); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale: RATIONALE }); + let successfulResult = (await retry.results())[0]!; + expect(String(successfulResult.output)).toContain('"ok": true'); + expect(successfulResult.saved.record).toEqual(record); + expect(successfulResult.saved.source).toBe(Plan.source(plan)); + expect(successfulResult.saved.jobs).toMatchObject([{ status: "running", attempts: 1 }]); + expect(successfulResult.saved.transcript).toHaveLength(1); + let donePublication = retry.publications.find(({ frame }) => + frame.kind === "conversation-plan:jobs" + && (frame.jobs as Array<{ status: string }>)[0]?.status === "done" + )!; + expect(donePublication.saved.jobs).toEqual([done]); + expect(donePublication.saved.record).toEqual(record); + expect(donePublication.saved.source).toBe(Plan.source(plan)); + expect(donePublication.saved.transcript).toMatchObject([ + { id: "m1", text: "We need to choose authentication." }, + { author: { kind: "system" }, text: `Chopin refined ${TITLE}` }, + ]); + expect(donePublication.saved.transcript).toHaveLength(2); + expect(retry.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(retry.driver.calls).toMatchObject([{ + toolName: "refine_decision", + input: { id, title: TITLE }, + }]); + expect(retry.driver.starts()).toBe(1); + expect(retry.driver.destroyed()).toBe(1); + expect(retry.identity.driver.starts()).toBe(0); + expect(retry.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(retry.errors).toEqual([]); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); + expect(plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ) + .toBe(false); + for (let observation of [...first.publications, ...retry.publications]) { + if (observation.frame.kind === "question:meta") { + expect(observation.frame.id).toBe(id); + expect(observation.frame.meta).toMatchObject({ + status: observation.saved.record!.status, + origin: observation.saved.record!.origin, + optionOrigins: observation.saved.record!.optionOrigins, + refining: observation.saved.jobs.some(job => + job.kind === "refine" && ["pending", "running"].includes(job.status) + ), + }); + } + if (observation.frame.kind === "question:changed") { + expect(observation.frame.definition).toEqual(observation.saved.record!.definition); + } + } + let changed = retry.publications.filter(({ frame }) => frame.kind === "question:changed"); + expect(changed).toHaveLength(2); + expect(changed.at(-1)!.frame.definition).toEqual(record.definition); + let metas = retry.publications.filter(({ frame }) => frame.kind === "question:meta"); + expect(metas.at(-1)!.frame.meta).toMatchObject({ + refining: false, + optionOrigins: record.optionOrigins, + }); + let completedSource = Plan.source(plan); + await retry.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + current = undefined; + expect(plan.records.get(id)).toEqual(record); + expect(plan.conversationPlanJobs).toEqual([done]); + expect(Plan.source(plan)).toBe(completedSource); + expect(plan.chat.entries).toHaveLength(2); + expect(plan.chat.waiting).toEqual([]); + current = await scriptedRefine({ ...opened, plan }, dir, id, first.identity); + current.jobs.wake(); + await current.jobs.idle(); + expect(current.driver.starts()).toBe(0); + expect(current.driver.destroyed()).toBe(0); + expect(current.counts()).toEqual({ + setups: 0, + unexpectedCommands: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + expect(await current.jobs.retry(done.id)).toBe(false); + } finally { + if (current) await current.close(); + else if (!plan.chat.closed) await Plan.close(plan); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/conversation-plan/jobs-restore.test.ts b/apps/server/src/conversation-plan/jobs-restore.test.ts new file mode 100644 index 00000000..a089560c --- /dev/null +++ b/apps/server/src/conversation-plan/jobs-restore.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; + +import * as Jobs from "./jobs"; +import { AT, card } from "./jobs.test-fixtures"; + +describe("Planner job queue", () => { + test("restore converts an interrupted running job to a failed, non-replayed job", () => { + let running = Jobs.start(Jobs.enqueue([], card("m1"), AT), "refine:W1:m1", AT); + let restored = Jobs.restore(JSON.parse(JSON.stringify(running))); + expect(restored[0]).toEqual({ + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "failed", + attempts: 0, + reason: "interrupted", + at: AT, + }); + expect(Jobs.next(restored)).toBeUndefined(); + expect(Jobs.restore(undefined)).toEqual([]); + }); + + test("restore rejects malformed structure, identity, bounds, and duplicate work", () => { + let pending = Jobs.enqueue([], card("m1"), AT)[0]!; + let cases: Array<[string, unknown]> = [ + ["null", null], + ["not an array", {}], + [ + "over capacity", + Array.from({ length: Jobs.MAX_JOBS + 1 }, (_, index) => ({ + ...pending, + id: `refine:W${index}:m1`, + target: `W${index}`, + })), + ], + ["missing field", [{ id: pending.id }]], + ["unknown field", [{ ...pending, extra: true }]], + ["wrong kind", [{ ...pending, kind: "other" }]], + ["wrong id", [{ ...pending, id: "refine:W1:other" }]], + ["blank target", [{ ...pending, target: " " }]], + ["long target", [{ ...pending, target: "x".repeat(201) }]], + ["blank trigger", [{ ...pending, trigger: " " }]], + ["long trigger", [{ ...pending, trigger: "x".repeat(201) }]], + ["bad timestamp", [{ ...pending, at: "tomorrow" }]], + ["noncanonical timestamp", [{ ...pending, at: "2026-09-25T10:00:00Z" }]], + ["negative attempts", [{ ...pending, attempts: -1 }]], + ["fractional attempts", [{ ...pending, attempts: 1.5 }]], + ["duplicate ids", [pending, { ...pending }]], + ]; + for (let [name, value] of cases) { + expect(() => Jobs.restore(value), name).toThrow("invalid Planner job"); + } + }); + + test("restore rejects impossible status details and concurrent runners", () => { + let pending = Jobs.enqueue([], card("m1"), AT)[0]!; + let running = Jobs.start([pending], pending.id, AT)[0]!; + let done = Jobs.settle([running], running.id, { status: "done", output: "{}" }, AT)[0]!; + let failed = Jobs.settle([running], running.id, { + status: "failed", + reason: "Planner unavailable", + }, AT)[0]!; + let skipped = Jobs.settle([running], running.id, { + status: "skipped", + reason: "AGENT=off", + }, AT)[0]!; + let cases: Array<[string, unknown]> = [ + ["pending reason", [{ ...pending, reason: "old" }]], + ["running output", [{ ...running, output: "{}" }]], + ["done missing output", [{ ...done, output: undefined }]], + ["done malformed JSON", [{ ...done, output: "not JSON" }]], + ["done huge output", [{ ...done, output: JSON.stringify("x".repeat(4096)) }]], + ["done reason", [{ ...done, reason: "old" }]], + ["done without attempt", [{ ...done, attempts: 0 }]], + ["failed missing reason", [{ ...failed, reason: undefined }]], + ["failed huge reason", [{ ...failed, reason: "x".repeat(501) }]], + ["failed output", [{ ...failed, output: "{}" }]], + ["failed without attempt", [{ ...failed, attempts: 0 }]], + ["skipped without attempt", [{ ...skipped, attempts: 0 }]], + ["two running", [running, { ...running, id: "refine:W2:m2", target: "W2", trigger: "m2" }]], + ["two pending card jobs", [pending, { + ...pending, + id: "suggest:W1:m2", + kind: "suggest", + trigger: "m2", + }]], + ]; + for (let [name, value] of cases) { + expect(() => Jobs.restore(value), name).toThrow("invalid Planner job"); + } + expect( + Jobs.restore([ + done, + { ...failed, id: "refine:W2:m2", target: "W2", trigger: "m2" }, + { ...skipped, id: "refine:W3:m3", target: "W3", trigger: "m3" }, + ]).map(job => job.status), + ).toEqual([ + "done", + "failed", + "skipped", + ]); + }); +}); diff --git a/apps/server/src/conversation-plan/jobs-validation.test.ts b/apps/server/src/conversation-plan/jobs-validation.test.ts new file mode 100644 index 00000000..6b1ec6c6 --- /dev/null +++ b/apps/server/src/conversation-plan/jobs-validation.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; + +import * as Jobs from "./jobs"; +import { AT, card } from "./jobs.test-fixtures"; + +describe("Planner job queue", () => { + test("queue transitions reject data that could not be restored", () => { + let jobs = Jobs.enqueue([], card("m1"), AT); + expect(() => + Jobs.enqueue(jobs, { + kind: "heading", + target: "wrong", + trigger: "m2", + }, AT) + ).toThrow("invalid Planner job"); + expect(() => + Jobs.enqueue(jobs, { + kind: "refine", + target: "x".repeat(201), + trigger: "m2", + }, AT) + ).toThrow("invalid Planner job"); + expect(() => + Jobs.enqueue(jobs, { + kind: "refine", + target: "W2", + trigger: "x".repeat(201), + }, AT) + ).toThrow("invalid Planner job"); + expect(() => Jobs.enqueue(jobs, card("m2"), "yesterday")).toThrow("invalid Planner job"); + expect(() => Jobs.start(jobs, "refine:W1:m1", "yesterday")) + .toThrow("invalid Planner job"); + let running = Jobs.start(jobs, "refine:W1:m1", AT); + expect(() => + Jobs.settle(running, "refine:W1:m1", { + status: "done", + output: "not JSON", + }, AT) + ).toThrow("invalid Planner job"); + expect(() => + Jobs.settle(running, "refine:W1:m1", { + status: "done", + output: JSON.stringify("x".repeat(4096)), + }, AT) + ).toThrow("invalid Planner job"); + expect(() => + Jobs.settle(running, "refine:W1:m1", { + status: "failed", + reason: "x".repeat(501), + }, AT) + ).toThrow("invalid Planner job"); + let failed = Jobs.settle(running, "refine:W1:m1", { + status: "failed", + reason: "temporarily unavailable", + }, AT); + expect(() => Jobs.retry(failed, "refine:W1:m1", "yesterday")) + .toThrow("invalid Planner job"); + expect(jobs[0]?.status).toBe("pending"); + expect(running[0]?.status).toBe("running"); + }); + + test("retry waits while newer card work for the same target is pending", () => { + let failed = Jobs.settle( + Jobs.start(Jobs.enqueue([], card("m1"), AT), "refine:W1:m1", AT), + "refine:W1:m1", + { status: "failed", reason: "Planner unavailable" }, + AT, + ); + let jobs = Jobs.enqueue(failed, card("m2", "suggest"), AT); + expect(Jobs.retry(jobs, "refine:W1:m1", AT)).toBe(jobs); + let after = Jobs.settle(Jobs.start(jobs, "suggest:W1:m2", AT), "suggest:W1:m2", { + status: "done", + output: "{}", + }, AT); + expect(Jobs.retry(after, "refine:W1:m1", AT)[0]?.status).toBe("pending"); + }); + + test("card targets cannot make two intents share a colon-delimited job ID", () => { + let valid = { kind: "refine" as const, target: "W1", trigger: "m2:m3" }; + let ambiguous = { kind: "refine" as const, target: "W1:m2", trigger: "m3" }; + expect(Jobs.jobId(valid)).toBe("refine:W1:m2:m3"); + expect(() => Jobs.jobId(ambiguous)).toThrow("invalid Planner job"); + expect(() => Jobs.enqueue([], ambiguous, AT)).toThrow("invalid Planner job"); + let raw = { + ...Jobs.enqueue([], valid, AT)[0]!, + target: ambiguous.target, + trigger: ambiguous.trigger, + }; + expect(() => Jobs.restore([raw])).toThrow("invalid Planner job"); + }); +}); diff --git a/apps/server/src/conversation-plan/jobs.test-fixtures.ts b/apps/server/src/conversation-plan/jobs.test-fixtures.ts new file mode 100644 index 00000000..d033f7f8 --- /dev/null +++ b/apps/server/src/conversation-plan/jobs.test-fixtures.ts @@ -0,0 +1,5 @@ +export const AT = "2026-09-25T10:00:00.000Z"; + +export function card(trigger: string, kind: "refine" | "suggest" = "refine") { + return { kind, target: "W1", trigger }; +} diff --git a/apps/server/src/conversation-plan/jobs.test.ts b/apps/server/src/conversation-plan/jobs.test.ts new file mode 100644 index 00000000..ef2b6dfe --- /dev/null +++ b/apps/server/src/conversation-plan/jobs.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; + +import * as Jobs from "./jobs"; +import { AT, card } from "./jobs.test-fixtures"; + +describe("Planner job queue", () => { + test("a trigger mints one stable pending job", () => { + let once = Jobs.enqueue([], card("m1"), AT); + expect(once).toEqual([{ + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "pending", + attempts: 0, + at: AT, + }]); + expect(Jobs.enqueue(once, card("m1"), AT)).toBe(once); + }); + + test("pending card work coalesces, with refine taking the pending place", () => { + let jobs = Jobs.enqueue([], { kind: "heading", target: "document", trigger: "m0" }, AT); + jobs = Jobs.enqueue(jobs, card("m1", "suggest"), AT); + expect(Jobs.enqueue(jobs, card("m2", "suggest"), AT)).toBe(jobs); + jobs = Jobs.enqueue(jobs, card("m3", "refine"), AT); + expect(jobs.map(job => job.id)).toEqual([ + "heading:document:m0", + "refine:W1:m3", + ]); + expect(Jobs.enqueue(jobs, card("m4", "suggest"), AT)).toBe(jobs); + expect(Jobs.enqueue(jobs, card("m5", "refine"), AT)).toBe(jobs); + }); + + test("only the first pending job starts, and running work keeps later work queued", () => { + let jobs = Jobs.enqueue([], card("m1"), AT); + jobs = Jobs.enqueue(jobs, { kind: "suggest", target: "W2", trigger: "m2" }, AT); + expect(Jobs.next(jobs)?.id).toBe("refine:W1:m1"); + expect(Jobs.start(jobs, "suggest:W2:m2", AT)).toBe(jobs); + jobs = Jobs.start(jobs, "refine:W1:m1", AT); + expect(Jobs.next(jobs)).toBeUndefined(); + expect(Jobs.start(jobs, "suggest:W2:m2", AT)).toBe(jobs); + let queued = Jobs.enqueue(jobs, card("m3", "suggest"), AT); + expect(queued.map(job => [job.id, job.status])).toEqual([ + ["refine:W1:m1", "running"], + ["suggest:W2:m2", "pending"], + ["suggest:W1:m3", "pending"], + ]); + let done = Jobs.settle(queued, "refine:W1:m1", { status: "done", output: "{}" }, AT); + expect(done[0]).toMatchObject({ status: "done", attempts: 1, output: "{}" }); + expect(Jobs.next(done)?.id).toBe("suggest:W2:m2"); + }); + + test("full unfinished queue rejects new work but evicts the oldest done job", () => { + let jobs: Jobs.Job[] = []; + for (let index = 0; index < Jobs.MAX_JOBS; index++) { + jobs = Jobs.enqueue(jobs, { + kind: "suggest", + target: `W${index}`, + trigger: "m", + }, AT); + } + let same = Jobs.enqueue(jobs, { kind: "suggest", target: "W1", trigger: "new" }, AT); + expect(same).toBe(jobs); + let late = { kind: "refine" as const, target: "W129", trigger: "late" }; + expect(() => Jobs.enqueue(jobs, late, AT)).toThrow("Planner job queue is full"); + expect(jobs).toHaveLength(Jobs.MAX_JOBS); + jobs = Jobs.settle(Jobs.start(jobs, "suggest:W0:m", AT), "suggest:W0:m", { + status: "done", + output: "{}", + }, AT); + let grown = Jobs.enqueue(jobs, late, AT); + expect(grown).toHaveLength(Jobs.MAX_JOBS); + expect(grown.some(job => job.id === "suggest:W0:m")).toBe(false); + expect(grown.at(-1)?.id).toBe("refine:W129:late"); + }); + + test("128 skipped jobs leave room for new Planner work", () => { + let jobs: Jobs.Job[] = []; + for (let index = 0; index < Jobs.MAX_JOBS; index++) { + let intent = card(`m${index}`); + jobs = Jobs.enqueue(jobs, intent, AT); + jobs = Jobs.start(jobs, Jobs.jobId(intent), AT); + jobs = Jobs.settle(jobs, Jobs.jobId(intent), { + status: "skipped", + reason: "The Planner is off (AGENT=off).", + }, AT); + } + let next = Jobs.enqueue(jobs, card("m128"), AT); + expect(next).toHaveLength(Jobs.MAX_JOBS); + expect(next.some(job => job.id === "refine:W1:m0")).toBe(false); + expect(next.at(-1)).toMatchObject({ id: "refine:W1:m128", status: "pending" }); + expect(Jobs.enqueue(next, card("m128"), AT)).toBe(next); + }); + + test("evicting skipped history preserves retryable failed work", () => { + let failedIntent = card("failed"); + let jobs = Jobs.enqueue([], failedIntent, AT); + jobs = Jobs.start(jobs, Jobs.jobId(failedIntent), AT); + jobs = Jobs.settle(jobs, Jobs.jobId(failedIntent), { + status: "failed", + reason: "The Planner could not be reached.", + }, AT); + for (let index = 0; index < Jobs.MAX_JOBS - 1; index++) { + let intent = card(`skipped-${index}`); + jobs = Jobs.enqueue(jobs, intent, AT); + jobs = Jobs.start(jobs, Jobs.jobId(intent), AT); + jobs = Jobs.settle(jobs, Jobs.jobId(intent), { + status: "skipped", + reason: "The Planner is off (AGENT=off).", + }, AT); + } + let next = Jobs.enqueue(jobs, { kind: "suggest", target: "W2", trigger: "new" }, AT); + expect(next.some(job => job.id === Jobs.jobId(failedIntent))).toBe(true); + expect(next.some(job => job.id === "refine:W1:skipped-0")).toBe(false); + expect(Jobs.retry(next, Jobs.jobId(failedIntent), AT)[0]?.status).toBe("pending"); + }); + + test("only failed work can be retried, without forgetting its attempt", () => { + let jobs = Jobs.enqueue([], card("m1"), AT); + expect(Jobs.retry(jobs, "refine:W1:m1", AT)).toBe(jobs); + jobs = Jobs.start(jobs, "refine:W1:m1", AT); + expect(Jobs.retry(jobs, "refine:W1:m1", AT)).toBe(jobs); + jobs = Jobs.settle(jobs, "refine:W1:m1", { + status: "failed", + reason: "Planner unavailable", + }, AT); + expect(jobs[0]).toMatchObject({ status: "failed", reason: "Planner unavailable", attempts: 1 }); + expect(Jobs.next(jobs)).toBeUndefined(); + let retried = Jobs.retry(jobs, "refine:W1:m1", AT); + expect(retried[0]).toMatchObject({ status: "pending", attempts: 1 }); + expect(retried[0]?.reason).toBeUndefined(); + expect(Jobs.retry(retried, "refine:W1:m1", AT)).toBe(retried); + let skipped = Jobs.settle(Jobs.start(retried, "refine:W1:m1", AT), "refine:W1:m1", { + status: "skipped", + reason: "AGENT=off", + }, AT); + expect(skipped[0]).toMatchObject({ status: "skipped", attempts: 2 }); + expect(Jobs.retry(skipped, "refine:W1:m1", AT)).toBe(skipped); + }); + + test("refining and message diagnostics show only their matching work", () => { + let jobs = Jobs.enqueue([], card("m1", "suggest"), AT); + expect(Jobs.refining(jobs, "W1")).toBe(false); + jobs = Jobs.enqueue(jobs, { kind: "refine", target: "W2", trigger: "m2" }, AT); + expect(Jobs.refining(jobs, "W2")).toBe(true); + expect(Jobs.forTrigger(jobs, "m2").map(job => job.id)).toEqual(["refine:W2:m2"]); + expect(Jobs.forTrigger(jobs, "m3")).toEqual([]); + jobs = Jobs.settle(Jobs.start(jobs, "suggest:W1:m1", AT), "suggest:W1:m1", { + status: "done", + output: "{}", + }, AT); + jobs = Jobs.settle(Jobs.start(jobs, "refine:W2:m2", AT), "refine:W2:m2", { + status: "done", + output: "{}", + }, AT); + expect(Jobs.refining(jobs, "W2")).toBe(false); + expect(Jobs.forTrigger(jobs, "m2")).toHaveLength(1); + }); +}); diff --git a/apps/server/src/conversation-plan/jobs.ts b/apps/server/src/conversation-plan/jobs.ts new file mode 100644 index 00000000..dbbc6887 --- /dev/null +++ b/apps/server/src/conversation-plan/jobs.ts @@ -0,0 +1,189 @@ +/** Pure transitions for the durable Planner job queue. */ + +import type { ConversationPlan } from "@chopin/protocol"; +import type { JobIntent } from "./effects"; + +export type Job = ConversationPlan.Job; +export type JobOutcome = + | { status: "done"; output: string } + | { status: "failed" | "skipped"; reason: string }; + +export const MAX_JOBS = 128; +const MAX_TARGET = 200; +const MAX_TRIGGER = 200; +const MAX_REASON = 500; +const MAX_OUTPUT = 4096; +const KINDS = new Set(["heading", "refine", "suggest", "prose"]); +const STATUSES = new Set(["pending", "running", "done", "failed", "skipped"]); + +function invalid(): never { + throw new Error("invalid Planner job in sidecar"); +} + +function bounded(value: unknown, max: number): value is string { + return typeof value === "string" && !!value.trim() && value.length <= max; +} + +function isoTime(value: unknown): value is string { + return typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + && Number.isFinite(Date.parse(value)) + && new Date(value).toISOString() === value; +} + +function jsonOutput(value: unknown): value is string { + if (!bounded(value, MAX_OUTPUT)) return false; + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +export function jobId(intent: JobIntent): string { + if (intent.target.includes(":")) invalid(); + return `${intent.kind}:${intent.target}:${intent.trigger}`; +} + +export function enqueue(jobs: Job[], intent: JobIntent, at: string): Job[] { + if ( + !KINDS.has(intent.kind) || !bounded(intent.target, MAX_TARGET) + || !bounded(intent.trigger, MAX_TRIGGER) + || (intent.kind === "heading") !== (intent.target === "document") + || !isoTime(at) + ) invalid(); + let id = jobId(intent); + if (jobs.some(job => job.id === id)) return jobs; + if (intent.kind === "refine" || intent.kind === "suggest") { + let pending = jobs.findIndex(job => + (job.kind === "refine" || job.kind === "suggest") + && job.target === intent.target && job.status === "pending" + ); + if (pending >= 0) { + if (intent.kind === "suggest" || jobs[pending]!.kind === "refine") return jobs; + return jobs.map((job, index) => + index === pending ? { ...job, id, kind: "refine", trigger: intent.trigger, at } : job + ); + } + } + let available = jobs; + if (jobs.length >= MAX_JOBS) { + let oldestTerminal = jobs.findIndex(job => job.status === "done" || job.status === "skipped"); + if (oldestTerminal < 0) throw new Error("Planner job queue is full"); + available = jobs.filter((_, index) => index !== oldestTerminal); + } + return [...available, { id, ...intent, status: "pending", attempts: 0, at }]; +} + +export function next(jobs: Job[]): Job | undefined { + if (jobs.some(job => job.status === "running")) return; + return jobs.find(job => job.status === "pending"); +} + +export function start(jobs: Job[], id: string, at: string): Job[] { + if (next(jobs)?.id !== id) return jobs; + if (!isoTime(at)) invalid(); + return jobs.map(job => job.id === id ? { ...job, status: "running", at } : job); +} + +export function settle(jobs: Job[], id: string, outcome: JobOutcome, at: string): Job[] { + let running = jobs.find(job => job.id === id && job.status === "running"); + if (!running) return jobs; + if ( + !isoTime(at) || running.attempts >= Number.MAX_SAFE_INTEGER + || (outcome.status === "done" + ? !jsonOutput(outcome.output) + : !["failed", "skipped"].includes(outcome.status) + || !bounded(outcome.reason, MAX_REASON)) + ) invalid(); + return jobs.map(job => { + if (job.id !== id) return job; + let { reason: _reason, output: _output, ...rest } = job; + return outcome.status === "done" + ? { ...rest, status: "done", attempts: job.attempts + 1, output: outcome.output, at } + : { ...rest, status: outcome.status, attempts: job.attempts + 1, reason: outcome.reason, at }; + }); +} + +export function retry(jobs: Job[], id: string, at: string): Job[] { + let failed = jobs.find(job => job.id === id && job.status === "failed"); + if (!failed) return jobs; + if ( + (failed.kind === "refine" || failed.kind === "suggest") + && jobs.some(job => + job.status === "pending" && (job.kind === "refine" || job.kind === "suggest") + && job.target === failed.target + ) + ) return jobs; + if (!isoTime(at)) invalid(); + return jobs.map(job => { + if (job.id !== id) return job; + let { reason: _reason, ...rest } = job; + return { ...rest, status: "pending", at }; + }); +} + +export function refining(jobs: Job[], questionnaireId: string): boolean { + return jobs.some(job => + job.kind === "refine" && job.target === questionnaireId + && (job.status === "pending" || job.status === "running") + ); +} + +export function forTrigger(jobs: Job[], messageId: string): Job[] { + return jobs.filter(job => job.trigger === messageId); +} + +export function restore(value: unknown): Job[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_JOBS) invalid(); + let ids = new Set(); + let pendingCards = new Set(); + let running = 0; + for (let raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) invalid(); + let job = raw as Record; + let required = ["id", "kind", "target", "trigger", "status", "attempts", "at"]; + if ( + required.some(key => !Object.hasOwn(job, key)) + || Object.keys(job).some(key => ![...required, "reason", "output"].includes(key)) + || !KINDS.has(job.kind as string) + || !STATUSES.has(job.status as string) + || !bounded(job.target, MAX_TARGET) + || (job.target as string).includes(":") + || !bounded(job.trigger, MAX_TRIGGER) + || !bounded(job.id, 420) + || job.id !== `${job.kind}:${job.target}:${job.trigger}` + || ids.has(job.id) + || !Number.isSafeInteger(job.attempts) || (job.attempts as number) < 0 + || !isoTime(job.at) + || (job.kind === "heading") !== (job.target === "document") + ) invalid(); + ids.add(job.id as string); + let hasReason = Object.hasOwn(job, "reason"); + let hasOutput = Object.hasOwn(job, "output"); + if (job.status === "pending" || job.status === "running") { + if (hasReason || hasOutput) invalid(); + if (job.status === "running" && ++running > 1) invalid(); + if ( + job.status === "pending" && (job.kind === "refine" || job.kind === "suggest") + ) { + if (pendingCards.has(job.target as string)) invalid(); + pendingCards.add(job.target as string); + } + } else if (job.status === "done") { + if (hasReason || !hasOutput || !jsonOutput(job.output) || job.attempts === 0) { + invalid(); + } + } else { + if ( + hasOutput || !hasReason || !bounded(job.reason, MAX_REASON) + || job.attempts === 0 && (job.status !== "failed" || job.reason !== "interrupted") + ) invalid(); + } + } + return value.map((job: Job) => + job.status === "running" ? { ...job, status: "failed", reason: "interrupted" } : job + ); +} diff --git a/apps/server/src/conversation-plan/option-admission.test.ts b/apps/server/src/conversation-plan/option-admission.test.ts new file mode 100644 index 00000000..63d0e943 --- /dev/null +++ b/apps/server/src/conversation-plan/option-admission.test.ts @@ -0,0 +1,530 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { applyInference, completeAnalysis, enqueue, initialState } from "./domain"; +import { cardEffects, mirrorCard } from "./cards"; +import { createProcessor } from "./service"; +import { entry, opened } from "./service.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Socket } from "../wire"; +import type { Dependencies } from "./processor-types"; +import type { JsonValue } from "../storage/model"; + +async function setup(optionCount: number, reasons = 0, interpret?: Dependencies["interpret"]) { + let context = await openPlan(); + let { plan, server } = context; + let question = entry("question", "Where should we host?"); + let excerpt = { + ...entry("excerpt", "Use the new provider."), + author: { kind: "member" as const, handle: "alice" }, + }; + plan.chat.entries = [question, excerpt]; + let state = applyInference(initialState(), opened(question), question); + let options = Array.from({ length: optionCount }, (_, index) => ({ + id: `01K0N4W3B7P27CBAEC7A8C8WE${index}`, + label: `Provider ${index}`, + })); + for ( + let [index, text] of [ + ...options.map(option => option.label), + ...Array.from({ length: reasons }, (_, i) => `Reason ${i}`), + ].entries() + ) { + let message = entry(`evidence-${index}`, text); + plan.chat.entries.push(message); + let kind: "option" | "reason" = index < options.length ? "option" : "reason"; + state = applyInference(state, { + id: `event-${index}`, + type: `${kind}.added`, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: text, + start: 0, + end: text.length, + role: kind, + }, + contribution: { + id: options[index]?.id ?? `reason-${index}`, + text, + authoring: "quoted", + targetId: "thread-a", + }, + }, message); + } + plan.conversationPlan = completeAnalysis(enqueue(state, excerpt.id), excerpt.id, [], excerpt, { + questionSetVersion: "conversation-plan-5", + modelVersion: "fixture", + status: "unlinked", + passes: [], + outcomes: [{ + start: 0, + end: excerpt.text.length, + status: "review", + gate: "unclassified excerpt", + eventIds: [], + }], + }); + await Plan.persist(plan); + let cardId = await Questions.insertConversationCard(plan, server, plan.id, { + threadId: "thread-a", + header: "Hosting", + question: question.text, + options, + }); + let errors: unknown[] = []; + let processor = createProcessor({ + plan, + interpret, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + active: () => true, + publish() {}, + onError: error => errors.push(error), + }); + let effects = cardEffects(plan, server, plan.id, processor, undefined, { + chat: plan.chat, + plan, + server, + room: plan.id, + }); + await effects.link("thread-a", cardId); + await processor.idle(); + let frames: Array> = []; + let ws = { + data: { handle: "carol", client: "carol", room: plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { + ...context, + cardId, + processor, + effects, + errors, + frames, + ws, + correct(kind: "option" | "reason" = "option") { + return processor.correct({ + actionId: "add-excerpt", + threadId: "thread-a", + expectedVersion: plan.conversationPlan.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: excerpt.id, + start: 0, + end: excerpt.text.length, + contributionKind: kind, + }, + }, { kind: "member", handle: "bob" }); + }, + append(label = "Another provider") { + return Questions.addOption(plan, server, plan.id, ws, { + kind: "question:option", + rid: "append", + ts: 0, + id: cardId, + question: Store.get(plan.questions, cardId)!.definition.questions[0]!.id, + key: "new-option", + label, + }); + }, + async save() { + let live = Store.get(plan.questions, cardId)!; + let model = live.model.fork(); + model.api.val([live.definition.questions[0]!.id, "choice"]).set(options[0]!.id); + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "select", + ts: 0, + id: cardId, + patch: [...model.api.flush()!.toBinary()], + }); + await Questions.submit(plan, server, plan.id, ws, { + kind: "question:submit", + rid: "save", + ts: 0, + id: cardId, + revision: Store.get(plan.questions, cardId)!.revision, + }); + }, + snapshot() { + return structuredClone({ + record: plan.records.get(cardId), + draft: Store.snapshot(plan.questions, cardId), + conversation: plan.conversationPlan, + source: Room.project(plan.document), + pending: plan.pendingCardActions, + pendingEffects: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + broadcasts: context.broadcasts, + storageRevision: plan.persistence.revision, + }); + }, + async close() { + processor.stop(); + await processor.idle(); + await Plan.close(plan); + }, + }; +} + +test("a full card refuses human option correction before claiming it was added", async () => { + let h = await setup(10); + try { + let before = h.snapshot(); + await expect(h.correct()).rejects.toThrow(/full|limit/i); + expect(h.snapshot()).toEqual(before); + expect(h.plan.conversationPlanPendingEffects.some(effect => effect.kind === "add-option")).toBe( + false, + ); + } finally { + await h.close(); + } +}); + +test("a full discussion refuses an option before acknowledgement and still permits Save", async () => { + let h = await setup(1, 63); + try { + let before = h.snapshot(); + await h.append(); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(h.snapshot()).toEqual(before); + await h.save(); + await mirrorCard(h.plan, h.processor); + expect(h.plan.records.get(h.cardId)?.status).toBe("answered"); + expect(h.plan.conversationPlan.threads[0]?.status).toBe("decided"); + expect(h.plan.pendingCardActions).toEqual([]); + } finally { + await h.close(); + } +}); + +test("a pending option mirror reserves the last discussion slot against corrections", async () => { + let h = await setup(1, 62); + try { + await h.append(); + expect(h.frames.at(-1)?.ok).toBe(true); + let before = h.snapshot(); + await expect(h.correct("reason")).rejects.toThrow(/contribution limit/i); + expect(h.snapshot()).toEqual(before); + await mirrorCard(h.plan, h.processor); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(64); + expect(h.plan.pendingCardActions).toEqual([]); + } finally { + await h.close(); + } +}); + +test("inference cannot consume a pending option mirror's reserved slot", async () => { + let h = await setup(1, 62, async ({ state, message }) => ({ + events: [{ + id: "reason:inferred", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "reason", + }, + contribution: { + id: "reason-inferred", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }], + analysis: { + questionSetVersion: "conversation-plan-5", + modelVersion: "fixture", + status: "applied", + passes: [], + }, + })); + try { + await h.append(); + let message = entry("more-evidence", "This provider is cheaper."); + await h.processor.accept(message); + h.processor.afterMessage(); + await h.processor.idle(); + expect(h.plan.conversationPlan.queue.find(item => item.messageId === message.id)?.status).toBe( + "failed", + ); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(63); + await mirrorCard(h.plan, h.processor); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(64); + expect(h.plan.pendingCardActions).toEqual([]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("an accepted excerpt reserves the last card slot across another writer and reopen", async () => { + let h = await setup(9); + let restored: Plan.Plan | undefined; + let recovered: ReturnType | undefined; + try { + let reply = await h.correct(); + await h.processor.idle(); + let before = h.snapshot(); + await h.append(); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(h.snapshot()).toEqual(before); + let event = h.plan.conversationPlan.events.find(item => item.id === reply.eventId)!; + if (event.type !== "option.added") throw new Error("missing accepted option"); + let key = `option:${event.contribution.id}`; + await h.close(); + restored = await Plan.open(h.plan.id, h.backend, h.server); + let plan = restored; + recovered = createProcessor({ + plan, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + active: () => true, + publish() {}, + }); + recovered.setEffects( + cardEffects(plan, h.server, plan.id, recovered, undefined, { + chat: plan.chat, + plan, + server: h.server, + room: plan.id, + }), + ); + await recovered.idle(); + expect(plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(10); + expect(plan.records.get(h.cardId)?.definition.questions[0]?.options.at(-1)?.id).toBe( + event.contribution.id, + ); + expect(plan.conversationPlanEffects).toContain(key); + expect(plan.conversationPlanPendingEffects.some(effect => effect.key === key)).toBe(false); + } finally { + recovered?.stop(); + await recovered?.idle(); + if (restored) await Plan.close(restored); + else await h.close(); + } +}); + +test("a classifier option ahead of a reserved human option cannot block its delivery", async () => { + let h = await setup(9); + try { + let message = entry("classifier-option", "An inferred provider"); + h.plan.chat.entries.push(message); + await h.processor.record({ + id: "classifier-option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: h.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "option", + }, + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEA", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }); + await h.correct(); + h.processor.setEffects(h.effects); + await h.processor.idle(); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options.at(-1)?.label).toBe( + "Use the new provider.", + ); + expect(h.plan.conversationPlanPendingEffects.filter(effect => effect.kind === "add-option")) + .toEqual([]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("an accepted excerpt reserves its label against another writer", async () => { + let h = await setup(1); + try { + await h.correct(); + let before = h.snapshot(); + await h.append("USE THE NEW PROVIDER."); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "duplicate" }); + expect(h.snapshot()).toEqual(before); + } finally { + await h.close(); + } +}); + +test("a Save after delivery target lookup supersedes the pending human option", async () => { + let h = await setup(1); + try { + let { runEffects } = await import("./effects"); + await h.correct(); + let option = h.plan.conversationPlanPendingEffects.find(effect => + effect.kind === "add-option" + )!; + let receipts: string[] = []; + let failures: unknown[] = []; + await runEffects({ + ...h.effects, + applied: () => false, + addOption: async (id, input) => { + await h.save(); + await h.effects.addOption(id, input); + }, + markApplied: async key => { + receipts.push(key); + }, + report: error => failures.push(error), + }, [option]); + expect(failures).toEqual([]); + expect(receipts).toEqual([option.key]); + expect(h.plan.records.get(h.cardId)?.status).toBe("answered"); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(1); + } finally { + await h.close(); + } +}); + +test("refused legacy human option delivery retains pending work without an applied receipt", async () => { + let h = await setup(10); + try { + // Recreate an outbox accepted by the previous implementation at an already-full card. + let { applyCorrection } = await import("./domain"); + let { effectsFor, runEffects } = await import("./effects"); + let next = applyCorrection( + h.plan.conversationPlan, + { + actionId: "legacy-full", + threadId: "thread-a", + expectedVersion: h.plan.conversationPlan.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: "excerpt", + start: 0, + end: "Use the new provider.".length, + contributionKind: "option", + }, + }, + { kind: "member", handle: "bob" }, + 1001, + h.plan.chat.entries, + ); + h.plan.conversationPlan = next; + let pending = effectsFor([next.events.at(-1)!], next, undefined, h.plan.records); + let receipts: string[] = []; + let failures: unknown[] = []; + await runEffects({ + ...h.effects, + applied: key => receipts.includes(key), + markApplied: async key => { + receipts.push(key); + }, + report: error => failures.push(error), + }, pending); + expect(failures).toHaveLength(1); + expect(String(failures[0])).toContain("accepted excerpt option: full"); + expect(receipts).toEqual([]); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(10); + } finally { + await h.close(); + } +}); + +test("a legacy over-reserved discussion reopens and permits unrelated commits without more overflow", async () => { + let h = await setup(1, 62); + let restored: Plan.Plan | undefined; + try { + await h.append(); + let message = entry("legacy-last-reason", "Evidence accepted before reservations existed."); + let saved = structuredClone(h.plan.persistence.committedSidecar) as { + [key: string]: JsonValue; + }; + let legacy = applyInference(h.plan.conversationPlan, { + id: "legacy-reason", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: h.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "reason", + }, + contribution: { + id: "legacy-reason", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, message); + saved.conversationPlan = JSON.parse(JSON.stringify(legacy)); + saved.transcript = JSON.parse(JSON.stringify([...h.plan.chat.entries, message])); + await h.close(); + // Write the prior release's valid durable shape, bypassing today's admission guard. + let durable = h.plan.persistence; + await h.storage.collaboration.commit({ + channelId: h.plan.id, + lease: h.lease, + expectedRevision: durable.revision, + operationId: "legacy-over-reservation", + epoch: durable.committedEpoch, + sidecar: saved, + events: [], + now: new Date(), + }); + restored = await Plan.open(h.plan.id, h.backend, h.server); + let pending = structuredClone(restored.pendingCardActions); + expect(restored.conversationPlan.threads[0]?.contributions).toHaveLength(64); + restored.chat.entries.push(entry("unrelated-note", "We should update the introduction.")); + await Plan.persist(restored); + expect(restored.pendingCardActions).toEqual(pending); + await Questions.addOption(restored, h.server, restored.id, h.ws, { + kind: "question:option", + rid: "overflow", + ts: 0, + id: h.cardId, + question: Store.get(restored.questions, h.cardId)!.definition.questions[0]!.id, + key: "another-option", + label: "Yet another provider", + }); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(restored.pendingCardActions).toEqual(pending); + await Plan.close(restored); + restored = await Plan.open(h.plan.id, h.backend, h.server); + expect(restored.chat.entries.at(-1)?.id).toBe("unrelated-note"); + expect(restored.pendingCardActions).toEqual(pending); + } finally { + if (restored) await Plan.close(restored); + else await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/option-capacity.ts b/apps/server/src/conversation-plan/option-capacity.ts new file mode 100644 index 00000000..797fc37a --- /dev/null +++ b/apps/server/src/conversation-plan/option-capacity.ts @@ -0,0 +1,106 @@ +import { limits } from "@chopin/question"; +import type { Plan } from "../plan/service"; +import type { Record } from "../questions/records"; +import { MAX_CONTRIBUTIONS } from "./validation-fields"; + +type Capacity = Pick & { + conversationPlan: Pick; +}; +type Budget = { key: string; used: number; limit: number; message: string; reason?: "duplicate" }; + +export class OptionCapacityError extends Error { + constructor(message: string, readonly reason: "full" | "duplicate" = "full") { + super(message); + } +} + +/** Accepted option work owns its slot until the record or thread contains its ID. */ +export function assertOptionCapacity(plan: Capacity & Partial>): void { + let previous: Budget[] | undefined; + for (let budget of budgets(plan)) { + if (budget.used <= budget.limit) continue; + if (!previous) { + // Existing durable excess must not make unrelated document writes unavailable. + let saved = plan.persistence?.committedSidecar as { + conversationPlan?: Capacity["conversationPlan"]; + conversationPlanPendingEffects?: Capacity["conversationPlanPendingEffects"]; + pendingCardActions?: Capacity["pendingCardActions"]; + questions: Record[]; + } | undefined; + previous = saved + ? budgets({ + conversationPlan: saved.conversationPlan ?? { threads: [], events: [] }, + conversationPlanPendingEffects: saved.conversationPlanPendingEffects ?? [], + pendingCardActions: saved.pendingCardActions ?? [], + records: new Map(saved.questions.map(record => [record.id, record])), + }) + : []; + } + if (budget.used <= (previous.find(item => item.key === budget.key)?.used ?? 0)) continue; + throw new OptionCapacityError(budget.message, budget.reason); + } +} + +function budgets(plan: Capacity): Budget[] { + let additions = plan.conversationPlanPendingEffects.filter(effect => + effect.kind === "add-option" + ); + if ( + !additions.length && !plan.pendingCardActions.some(action => action.kind === "option-added") + ) return []; + let triggers = new Set(additions.map(effect => effect.trigger)); + let humanOptions = new Map(); + for (let event of additions.length ? plan.conversationPlan.events : []) { + if (triggers.has(event.id) && event.origin === "human" && event.type === "option.added") { + humanOptions.set(event.id, event.contribution.id); + } + } + let result: Budget[] = []; + for (let thread of plan.conversationPlan.threads) { + let reserved = new Set(); + for (let action of plan.pendingCardActions) { + if ( + action.kind === "option-added" && action.threadId === thread.id + && !thread.contributions.some(item => item.id === action.optionId) + ) reserved.add(action.optionId); + } + result.push({ + key: JSON.stringify([thread.id, "contributions"]), + used: thread.contributions.length + reserved.size, + limit: MAX_CONTRIBUTIONS, + message: "This discussion has reached its contribution limit", + }); + let pending = additions.filter(effect => + effect.threadId === thread.id && humanOptions.get(effect.trigger) === effect.optionId + ); + if (!pending.length) continue; + let record = thread.questionnaireId && plan.records.get(thread.questionnaireId); + if (!record || !["open", "reopened"].includes(record.status)) continue; + let options = record.definition.questions[0]?.options ?? []; + let byId = new Map(options.map(option => [option.id, option.label])); + for (let effect of pending) { + if (!byId.has(effect.optionId)) byId.set(effect.optionId, effect.label); + } + let labels = new Map(); + for (let text of byId.values()) { + let label = text.trim().toLowerCase(); + labels.set(label, (labels.get(label) ?? 0) + 1); + } + result.push({ + key: JSON.stringify([thread.id, "options"]), + used: byId.size, + limit: limits.MAX_DECISION_OPTIONS, + message: "This card is full, including options waiting to be added", + }); + for (let [label, used] of labels) { + result.push({ + key: JSON.stringify([thread.id, "label", label]), + used, + limit: 1, + message: "This option is already on the card", + reason: "duplicate", + }); + } + } + return result; +} diff --git a/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts new file mode 100644 index 00000000..33bada86 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +test("declarative option capture refuses attribution, negation, withdrawal, ambiguity, and repeats", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let duplicateState = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Copilot.", + ); + let plain = "By agent access I mean Copilot or bring-your-own API keys."; + let assess = ( + text: string, + overrides: { + first?: Record; + second?: Record; + triage?: Record; + } = {}, + candidateState = state, + ) => { + let current = message(`guard-${text}`, text); + return planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: { + ...first({ new_option: 0.96 }), + thread_target: confidentChoice("access-thread"), + ...overrides.triage, + }, + candidates: extractQuotes(text).map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: "none", thread: "access-thread" }), + new_option: { type: "noul", noul: 0.93 }, + duplicate: { type: "noul", noul: 0.04 }, + ...(index ? overrides.second : overrides.first), + }, + })), + }); + }; + for ( + let text of [ + "Bob said, by agent access I mean Copilot or bring-your-own API keys.", + "By agent access I mean not Copilot or bring-your-own API keys.", + `${plain} Actually, I take that back.`, + "By agent access I mean Copilot or Copilot.", + ] + ) { + expect(extractQuotes(text)).not.toEqual([ + { quote: "Copilot", start: 23, end: 30 }, + { quote: "bring-your-own API keys", start: 34, end: 57 }, + ]); + expect(assess(text).events).toEqual([]); + } + expect(assess(plain, {}, duplicateState).events).toEqual([]); + expect(assess(plain, { triage: { thread_target: confidentChoice("new") } }).events) + .toEqual([]); + expect( + assess(plain, { triage: { c1_owned_unretracted: { type: "noul", noul: 0.2 } } }) + .events, + ).toEqual([]); + expect(assess(plain, { second: { thread: confidentChoice("none") } }).events).toEqual([]); + expect( + assess(plain, { second: { duplicate: { type: "noul", noul: 0.95 } } }) + .events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts new file mode 100644 index 00000000..9d7a4709 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { + buildCandidateTargetingRequest, + buildTargetingRequest, + buildTriageRequest, + QUESTION_SET_VERSION, +} from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded, settledBy } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("mixed-message assent names the unique pending choice in its own targeting questions", () => { + let current = message( + "mixed-assent", + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + "alice", + ); + let prior = message("prior-proposal", "Let's just go with the optional outline.", "bob"); + let request = buildTargetingRequest( + current, + [prior], + settledBy(seeded(), "bob").threads, + extractQuotes(current.text), + ); + let state = request.state as { + recent: Array<{ text: string }>; + candidates: Array<{ quote: string }>; + }; + expect(state.recent.at(-1)?.text).toBe(prior.text); + expect(state.candidates.map(candidate => candidate.quote)).toEqual([ + "Sounds good to me.", + "What about agent access?", + "Copilot?", + "BYO API keys?", + ]); + let target = request.questions.c0_thread as Extract; + expect(target.instructions).toContain("candidates[0].quote"); + expect(target.instructions).toContain("later clauses"); + expect(target.criteria["thread-a"].toLowerCase()).toContain("pending proposal to settle"); + expect(target.criteria["thread-a"]).toContain("Use an optional outline."); + let agreement = request.questions.c0_agrees_with_settle; + expect(agreement?.instructions).toContain("candidates[0].quote"); + expect(agreement?.instructions).toContain("Use an optional outline."); + expect(agreement?.instructions).toContain("bob"); + expect(agreement?.instructions).toContain("later clauses"); + expect(agreement?.instructions).toContain("quoted"); + expect(agreement?.instructions).toContain("sarcasm"); +}); + +test("v5 triage checks exact quote ownership while targeting isolates each clause", () => { + let current = message( + "mixed-assent", + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + "Jules", + ); + let recent = [ + message("purpose", "We need to figure out auth.", "Mina"), + message("alternatives", "Auth0, custom login, or GitHub auth?", "Jules"), + message("proposal", "Let's just go with GitHub.", "Mina"), + ]; + let quotes = extractQuotes(current.text); + let state = settledBy(seeded(), "Mina"); + let triage = buildTriageRequest(current, recent, state.threads, quotes); + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + expect(Object.keys(triage.questions).filter(key => key.endsWith("_owned_unretracted"))) + .toEqual([ + "c0_owned_unretracted", + "c1_owned_unretracted", + "c2_owned_unretracted", + "c3_owned_unretracted", + ]); + expect( + (triage.state as { candidates: Array<{ quote: string; start: number; end: number }> }) + .candidates, + ).toEqual(quotes); + for (let index = 0; index < quotes.length; index++) { + let request = buildCandidateTargetingRequest( + current, + recent, + state.threads, + quotes, + index, + ); + let visible = request.state as { + current: { id: string; text: string }; + preceding?: string; + recent: Array<{ id: string }>; + candidates: Array<{ quote: string; start: number; end: number }>; + }; + expect(Object.keys(request.questions).every(key => key.startsWith(`c${index}_`))) + .toBe(true); + expect(visible.current).toMatchObject({ id: current.id, text: quotes[index]!.quote }); + expect(visible.candidates.at(-1)).toEqual(quotes[index]); + expect(visible.candidates).toHaveLength(index + 1); + expect(visible.recent.map(entry => entry.id)).toEqual(recent.map(entry => entry.id)); + expect(visible.preceding ?? "").toBe( + current.text.slice(0, quotes[index]!.start) + .slice(-300), + ); + if (index < 3) expect(JSON.stringify(request.state)).not.toContain("BYO API keys?"); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts new file mode 100644 index 00000000..ca297ca3 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +test("captures a causal reason despite a near tie with support, with a clear existing option", () => { + let state = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + let current = message("sqs-reason", "SQS would reduce operations for us."); + let answers = { + ...follow({ role: "reason", thread: "queue-thread" }), + role: { + type: "choice" as const, + choice: "support", + confidence: 0.38, + probabilities: { reason: 0.39, support: 0.45, option: 0.13, resolution: 0.03 }, + }, + thread: { + type: "choice" as const, + choice: "queue-thread", + confidence: 0.99, + probabilities: { "queue-thread": 1, none: 0 }, + }, + option: { + type: "choice" as const, + choice: "sqs-option", + confidence: 1, + probabilities: { "sqs-option": 1, none: 0 }, + }, + relation: { + type: "choice" as const, + choice: "supports", + confidence: 0.96, + probabilities: { supports: 0.96, unrelated: 0.04 }, + }, + planning_substance: { type: "noul" as const, noul: 0.87 }, + support: { type: "noul" as const, noul: 0.86 }, + duplicate: { type: "noul" as const, noul: 0.12 }, + }; + let triage = { + ...first({ reason: 0.91, c0_owned_unretracted: 0.94 }), + thread_target: { + type: "choice" as const, + choice: "queue-thread", + confidence: 0.8, + probabilities: { "queue-thread": 0.87, none: 0.13 }, + }, + significance: { type: "score" as const, score: 1.41 }, + }; + let assess = ( + text = current.text, + candidate = answers as Record, + firstAnswers = triage as Record, + ) => + planEvents({ + channelId: "channel", + message: message("sqs-reason", text), + state, + first: firstAnswers, + candidates: [{ quote: text, start: 0, end: text.length, answers: candidate }], + }); + let accepted = assess(); + expect(accepted.events.map(event => event.type)).toEqual(["reason.added"]); + expect(accepted.events[0]).toMatchObject({ + threadId: "queue-thread", + source: { quote: current.text, role: "reason" }, + contribution: { targetId: "sqs-option", relation: "supports" }, + }); + expect( + assess(current.text, { + ...answers, + role: { + ...answers.role, + choice: "support", + probabilities: { reason: 0.44, support: 0.48, none: 0.08 }, + }, + }).events.map(event => event.type), + ).toEqual(["reason.added"]); + expect(assess(current.text, { ...answers, thread: confidentChoice("none") }).events) + .toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + thread_target: confidentChoice("none"), + }).events, + ).toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + c0_owned_unretracted: { type: "noul", noul: 0.3 }, + }).events, + ).toEqual([]); + expect(assess(current.text, { ...answers, option: confidentChoice("none") }).events) + .toEqual([]); + expect(assess(current.text, { ...answers, relation: confidentChoice("unrelated") }).events) + .toEqual([]); + expect( + assess(current.text, answers, { ...triage, reason: { type: "noul", noul: 0.4 } }) + .events, + ).toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + significance: { type: "score", score: 0.9 }, + }).events, + ).toEqual([]); + expect( + assess(current.text, { + ...answers, + role: { + ...answers.role, + probabilities: { reason: 0.36, support: 0.4, option: 0.24 }, + }, + }).events, + ).toEqual([]); + expect(assess("I prefer SQS.").events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts new file mode 100644 index 00000000..22838abe --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { buildTargetingRequest, buildTriageRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("candidate corroboration stays within the bounded second batch", () => { + let current = message("batch", "Use an outline. Keep it optional. Remember my choice."); + let request = buildTargetingRequest(current, [], seeded().threads, extractQuotes(current.text)); + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + // The seeded thread has no prior contributions, so none of the three spans asks duplicate. + expect(Object.keys(request.questions)).toHaveLength(36); + expect(Object.keys(request.questions).filter(key => key.endsWith("_duplicate"))).toEqual([]); + expect(request.questions.c2_new_option?.type).toBe("noul"); + expect(request.questions.c2_objection?.type).toBe("noul"); +}); + +test("question set four asks about purpose and re-raising only when discarded threads exist", () => { + let current = message("question-set", "Should we revisit the outline?"); + let threads = seeded().threads; + let triage = buildTriageRequest(current, [], threads); + expect(triage.questions.enough_purpose?.type).toBe("noul"); + expect(triage.questions.explicit_resolution?.instructions).toContain("propose"); + let candidate = extractQuotes(current.text); + let active = buildTargetingRequest(current, [], threads, candidate); + expect(active.questions.c0_raises_again).toBeUndefined(); + let discardedThreads = [{ ...threads[0], status: "discarded" as const }]; + let discarded = buildTargetingRequest(current, [], discardedThreads, candidate); + expect(discarded.questions.c0_raises_again?.type).toBe("noul"); + expect(discarded.questions.c0_explicit_resolution?.instructions).toContain("propose"); +}); + +test("purpose triage accepts a named planning topic without requiring a chosen approach", () => { + let auth = buildTriageRequest( + message("auth-purpose", "Okay we gotta figure out what we're doing for auth"), + [], + [], + ); + let vague = buildTriageRequest(message("vague", "We need to make a plan soon"), [], []); + let purpose = auth.questions.enough_purpose; + expect(purpose?.type).toBe("noul"); + if (purpose?.type !== "noul") throw new Error("purpose question is missing"); + expect(purpose.instructions).toContain("concrete planning topic"); + expect(purpose.instructions).toContain("Do not require a chosen solution"); + expect(purpose.criteria?.true).toContain("topic and intent to plan"); + expect(purpose.criteria?.false).toContain("no identifiable topic"); + expect((auth.state as { current: { text: string } }).current.text).toContain("auth"); + expect((vague.state as { current: { text: string } }).current.text).toBe( + "We need to make a plan soon", + ); +}); + +test("question triage separates planning purpose from a distinct answerable choice", () => { + let request = buildTriageRequest( + message("auth-purpose", "Okay we gotta figure out what we're doing for auth"), + [], + [], + ); + let question = request.questions.new_question; + expect(question?.type).toBe("noul"); + if (question?.type !== "noul") throw new Error("new question triage is missing"); + expect(question.instructions).toContain("distinct unresolved planning decision"); + expect(question.instructions).toContain("even when phrased as a statement"); + expect(question.instructions).toContain("merely announcing intent"); + expect(question.criteria?.true).toContain("declarative choice"); + expect(question.criteria?.false).toContain("no distinct question"); +}); + +test("triage selects a new target for an unanswered question with no current thread", () => { + let request = buildTriageRequest( + message("new-target", "Should we use Auth0 or build our own authentication?"), + [], + [], + ); + let target = request.questions.thread_target; + expect(target?.type).toBe("choice"); + if (target?.type !== "choice") throw new Error("thread targeting is missing"); + expect(target.instructions).toContain("If there are no current threads"); + expect(target.criteria.new).toContain("unanswered planning question"); + expect(target.criteria.none).toContain("No distinct planning question"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-commitment.test-fixtures.ts new file mode 100644 index 00000000..1794d366 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment.test-fixtures.ts @@ -0,0 +1,31 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { MockMutation } from "./pipeline-ordinary-save.test-fixtures"; + +export function hostingState(): ConversationPlan.State { + return stateWithOption( + "Where should the app be hosted?", + "hosting-thread", + "vps-option", + "Host the app on our own VPS.", + ); +} + +export function lowConfidenceResolution(threadId: string, optionId: string): MockMutation { + return (result, prefix) => { + if (!prefix) return; + Object.assign(result.answers[`${prefix}_role`], { + confidence: 0.73, + probabilities: { resolution: 0.76, option: 0.14, none: 0.1 }, + }); + Object.assign(result.answers[`${prefix}_chosen_option`], { + choice: optionId, + confidence: 0.87, + probabilities: { [optionId]: 0.87, new: 0.08, none: 0.05 }, + }); + Object.assign(result.answers[`${prefix}_thread`], { + confidence: 0.99, + probabilities: { [threadId]: 0.99, none: 0.01 }, + }); + }; +} diff --git a/apps/server/src/conversation-plan/pipeline-commitment.test.ts b/apps/server/src/conversation-plan/pipeline-commitment.test.ts new file mode 100644 index 00000000..93bf0585 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption } from "./policy-terminal.test-fixtures"; +import { + knownChoiceAnswers, + mockInterpret, + newChoiceAnswers, + proposalTriage, +} from "./pipeline-ordinary-save.test-fixtures"; +import { hostingState, lowConfidenceResolution } from "./pipeline-commitment.test-fixtures"; + +test("a noisy Jev commitment can suggest a known option but cannot auto-record", async () => { + let current = message("noisy-vps-commitment", "We've decided to host it on our own VPS."); + let state = hostingState(); + let output = await mockInterpret( + current, + state, + proposalTriage("hosting-thread", "commitment", { explicit_resolution: 0.69 }), + prefix => + knownChoiceAnswers(prefix, "hosting-thread", "vps-option", "resolution", { + chosen_option: 0.87, + explicit_resolution: 0.53, + }), + (result, prefix) => { + if (!prefix) { + result.answers.act.confidence = 0.94; + result.answers.act.probabilities = { commitment: 0.94, proposal: 0.03, other: 0.03 }; + return; + } + lowConfidenceResolution("hosting-thread", "vps-option")(result, prefix); + }, + ); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ + threadId: "hosting-thread", + optionId: "vps-option", + source: { quote: current.text, start: 0, end: current.text.length, role: "resolution" }, + }); + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); +}); + +test("reported and negated commitment variants never settle", async () => { + let state = hostingState(); + for ( + let [id, text] of [ + ["reported-vps-commitment", "Alice said we've decided to host it on our own VPS."], + ["negated-vps-commitment", "We've decided not to host it on our own VPS."], + ] as const + ) { + let current = message(id, text); + let output = await mockInterpret( + current, + state, + proposalTriage("hosting-thread", "commitment", { explicit_resolution: 0.69 }), + prefix => + knownChoiceAnswers(prefix, "hosting-thread", "vps-option", "resolution", { + explicit_resolution: 0.53, + }), + lowConfidenceResolution("hosting-thread", "vps-option"), + ); + expect(output.events).toEqual([]); + } +}); + +test("attributed commitments cannot trigger the rescue with strong Jev scores", async () => { + let state = hostingState(); + state = addThreadWithOption( + state, + "email-thread", + "Which service should send notification emails?", + "existing-mailgun", + "Use Mailgun for notification emails.", + ); + for ( + let [id, text, target, option] of [ + [ + "told-reported-vps-commitment", + "Alice told me we've decided to host it on our own VPS.", + "hosting-thread", + "vps-option", + ], + [ + "told-reported-new-choice", + "Alice told me let's do Postmark for notification emails.", + "email-thread", + "new", + ], + ] as const + ) { + let current = message(id, text); + let output = await mockInterpret( + current, + state, + proposalTriage(target, "commitment", { + explicit_resolution: 0.98, + new_option: 0.98, + c0_owned_unretracted: 0.95, + }), + prefix => + option === "new" + ? newChoiceAnswers(prefix, target, { explicit_resolution: 0.98 }) + : knownChoiceAnswers(prefix, target, option, "resolution", { + explicit_resolution: 0.98, + }), + ); + expect(output.events).toEqual([]); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts new file mode 100644 index 00000000..661aa7d4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts @@ -0,0 +1,98 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +export function stateWithOptions( + questionText: string, + threadId: string, + options: Array<{ id: string; text: string }>, +): ConversationPlan.State { + let [firstOption, ...rest] = options; + if (!firstOption) throw new Error("test thread needs an initial option"); + let state = stateWithOption(questionText, threadId, firstOption.id, firstOption.text); + for (let [index, option] of rest.entries()) { + let source = message(`option-${threadId}-${index + 1}`, option.text); + let thread = state.threads.find(item => item.id === threadId); + if (!thread) throw new Error("test option thread disappeared"); + state = applyInference(state, { + id: `option-added-${threadId}-${index + 1}`, + type: "option.added", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: source.ts, + source: { + messageId: source.id, + author: source.author as ConversationPlan.SourceAuthor, + quote: source.text, + start: 0, + end: source.text.length, + role: "option", + }, + contribution: { + id: option.id, + text: option.text, + authoring: "quoted", + targetId: threadId, + }, + }, source); + } + return state; +} + +export function planD03ContrastiveM2(role: "option" | "reason") { + let threadId = "notification-thread"; + let relayId = "smtp-relay"; + let state = stateWithOptions( + "Which service sends transactional notifications?", + threadId, + [ + { id: relayId, text: "SMTP relay" }, + { id: "postmark", text: "Postmark" }, + { id: "ses", text: "SES" }, + ], + ); + let current = message( + "d03-m2", + "relay is cheap but delivery reports are rough.", + "Dan", + ); + let candidate = { + quote: current.text, + start: 0, + end: current.text.length, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first(role === "option" ? { new_option: 0.98 } : { reason: 0.96 }), + act: confidentChoice(role === "option" ? "proposal" : "evaluation"), + thread_target: confidentChoice(threadId), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: [{ + ...candidate, + answers: { + ...follow({ role, thread: threadId }), + option: confidentChoice(role === "option" ? "new" : relayId), + ...(role === "option" ? { chosen_option: confidentChoice("new") } : {}), + ...(role === "reason" ? { relation: confidentChoice("supports") } : {}), + new_option: { type: "noul", noul: role === "option" ? 0.98 : 0.05 }, + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.02 }, + }, + }], + }); + return { result, current, relayId }; +} diff --git a/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts b/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts new file mode 100644 index 00000000..b47df93a --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { planD03ContrastiveM2, stateWithOptions } from "./pipeline-contrast.test-fixtures"; + +test("D04's R2 follow-up stays an option despite a comma and terminal or", () => { + let threadId = "upload-originals"; + let text = "is R2 still worth comparing, or did we rule it out?"; + let current = message("m4", text, "Teo"); + let state = stateWithOptions( + "Where should image originals live?", + threadId, + [ + { id: "s3", text: "S3 object storage" }, + { id: "shared-disk", text: "the existing shared disk" }, + ], + ); + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.97 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: [{ + quote: text, + start: 0, + end: 51, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + }], + }); + expect(result.events.map(event => event.type)).toEqual(["option.added"]); + expect(result.events[0]).toMatchObject({ + type: "option.added", + threadId, + source: { + messageId: "m4", + author: { kind: "member", handle: "Teo" }, + quote: text, + start: 0, + end: 51, + role: "option", + }, + contribution: { text, authoring: "quoted", targetId: threadId }, + }); +}); + +test("D03's contrastive m2 cannot add a fourth option when classified option/new", () => { + let { result } = planD03ContrastiveM2("option"); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); + +test("D03's contrastive m2 can remain a reason when independently classified", () => { + let { result, current, relayId } = planD03ContrastiveM2("reason"); + expect(result.events.map(event => event.type)).toEqual(["reason.added"]); + expect(result.events[0]).toMatchObject({ + type: "reason.added", + threadId: "notification-thread", + source: { quote: current.text, role: "reason" }, + contribution: { targetId: relayId }, + }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts b/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts new file mode 100644 index 00000000..3d6e003a --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { follow, message } from "./policy-initial.test-fixtures"; + +test("a first clause classified as both question and option keeps all three quoted options", () => { + let current = message( + "dual-role-options", + "Should we use an off the shelf solution like Auth0? Or roll our own? Or use something like GitHub auth?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let triage = mockResult(buildTriageRequest(current, [], [], quotes).questions, { + new_question: 0.97, + new_option: 0.91, + act: "question", + thread_target: "new", + significance: 2, + c0_owned_unretracted: 0.93, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.94, + }).answers; + let opening = follow({ role: "question", thread: "new", threadProbability: 0.92 }); + opening.role = { + type: "choice", + choice: "question", + confidence: 0.86, + probabilities: { question: 0.89, option: 0.11 }, + }; + opening.thread.confidence = 0.84; + opening.new_option = { type: "noul", noul: 0.91 }; + let second = follow({ role: "option", thread: "new" }); + second.role = { + type: "choice", + choice: "option", + confidence: 0.76, + probabilities: { option: 0.79, none: 0.21 }, + }; + second.thread = { + type: "choice", + choice: "new", + confidence: 0.32, + probabilities: { new: 0.66, none: 0.34 }, + }; + second.new_option = { type: "noul", noul: 0.94 }; + let third = follow({ role: "option", thread: "new" }); + third.role = { + type: "choice", + choice: "option", + confidence: 0.94, + probabilities: { option: 0.95, none: 0.05 }, + }; + third.thread = { + type: "choice", + choice: "new", + confidence: 0.51, + probabilities: { new: 0.75, none: 0.25 }, + }; + third.new_option = { type: "noul", noul: 0.94 }; + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates: [opening, second, third].map((answers, index) => ({ + ...quotes[index]!, + answers, + })), + }); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "option.added", + ]); + expect(output.events[0]).toMatchObject({ + question: current.text, + source: { quote: current.text, start: 0, end: current.text.length }, + }); + expect( + output.events.slice(1).map(event => + "source" in event && event.source + ? { quote: event.source.quote, start: event.source.start, end: event.source.end } + : undefined + ), + ) + .toEqual(quotes.map(quote => ({ quote: quote.quote, start: quote.start, end: quote.end }))); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts new file mode 100644 index 00000000..4a375384 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("D01 m7 independently agrees with Mei's pending Lexical spike choice", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let { current, quotes, result } = d01RobM7PolicyResult(linked, { state: pending.state }); + expect(quotes).toEqual([ + { quote: "yep, Lexical for the spike.", start: 0, end: 27 }, + { quote: "not a final library call yet.", start: 28, end: 57 }, + ]); + let agreements = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.agreed" + ); + + expect(agreements).toHaveLength(1); + expect(agreements[0]).toMatchObject({ + type: "scoped-choice.agreed", + id: expect.any(String), + proposalId: pending.proposal.id, + threadId: linked.threadId, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: pending.lexicalId, + label: "Lexical", + scope: "spike", + source: { + messageId: current.id, + author: { kind: "member", handle: "Rob" }, + quote: quotes[0]!.quote, + start: 0, + end: 27, + role: "support", + }, + }); + expect(result.events.filter(event => event.type === "settle.agreed")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect( + result.events.filter(event => (event as { type: string }).type === "card.answered"), + ).toEqual([]); + expect(pending.state.threads[0]!.pendingScopedChoice).toMatchObject({ + proposalId: pending.proposal.id, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: pending.lexicalId, + label: "Lexical", + scope: "spike", + proposer: "Mei", + messageId: "d01-m6", + }); + expect(pending.state.threads[0]!.pendingSettle).toBeUndefined(); + expect(pending.state.threads[0]!.decisionHistory).toEqual([]); + expect(linked.state.threads[0]!.status).toBe("exploring"); + expect(linked.linkedCards.get(linked.threadId)?.options).toEqual(linked.options); +}); + +test("D01 m7 agreement binds the latest pending proposal ID", () => { + let linked = d01LinkedEditorCard(); + let earlier = d01SeedScopedChoiceProposal(linked, "d01-m6-scoped-earlier"); + let current = d01SeedScopedChoiceProposal( + { ...linked, state: earlier.state }, + "d01-m6-scoped-current", + "d01-m6-scoped-current-message", + ); + let { result } = d01RobM7PolicyResult(linked, { state: current.state }); + let agreements = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.agreed" + ); + + expect(agreements).toHaveLength(1); + expect(agreements[0]).toMatchObject({ proposalId: current.proposal.id }); + expect(agreements[0]).not.toMatchObject({ proposalId: earlier.proposal.id }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts new file mode 100644 index 00000000..4ba6c86f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("an immediately conditioned Lexical preference cannot create a scoped Save", () => { + let { state, threadId, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let text = "I would pick Lexical for the spike; if pasted tables work."; + let current = message("d01-quoted-spike", text, "Mei"); + let quotes = extractQuotes(text); + expect(quotes).toEqual([ + { quote: "I would pick Lexical for the spike;", start: 0, end: 35 }, + { quote: "if pasted tables work.", start: 36, end: 58 }, + ]); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.98, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.95, + probabilities: { + question: 0, + proposal: 0.98, + evaluation: 0.01, + commitment: 0.01, + correction: 0, + other: 0, + }, + }, + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map((candidate, index) => ({ + ...candidate, + answers: index === 0 + ? { + ...follow({ role: "support", thread: threadId }), + option: confidentChoice(lexicalId), + chosen_option: confidentChoice(lexicalId), + relation: confidentChoice("supports"), + support: { type: "noul", noul: 0.99 }, + new_option: { type: "noul", noul: 0.9 }, + planning_substance: { type: "noul", noul: 0.9 }, + duplicate: { type: "noul", noul: 0.05 }, + } + : { + ...follow({ role: "none", thread: threadId }), + option: confidentChoice("none"), + chosen_option: confidentChoice("none"), + }, + })), + }); + + expect( + result.events.filter(event => (event as { type: string }).type === "scoped-choice.proposed"), + ).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(state.threads[0]!.pendingScopedChoice).toBeUndefined(); +}); + +test("a generic spike request without an option preference does not create a global Save", () => { + let { state, threadId, linkedCards } = d01LinkedEditorCard(); + let quote = "Let's run a quick spike before picking an editor."; + let current = message("d01-m5-generic-spike", quote, "Mei"); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ new_option: 0.05, c0_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: [{ + quote, + start: 0, + end: quote.length, + answers: { + ...follow({ role: "none", thread: threadId }), + option: confidentChoice("none"), + chosen_option: confidentChoice("none"), + new_option: { type: "noul", noul: 0.05 }, + planning_substance: { type: "noul", noul: 0.12 }, + }, + }], + }); + + expect(result.events).toEqual([]); + expect(state.threads[0]!.pendingSettle).toBeUndefined(); + expect(state.threads[0]!.decisionHistory).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts new file mode 100644 index 00000000..d756108b --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { first, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("D01 m6 cannot add a fifth option when targeting existing linked Lexical", () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let current = message("d01-m6", quote, "Mei"); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.88, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.95, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.73, + probabilities: { + question: 0, + proposal: 0.78, + evaluation: 0.2, + commitment: 0.02, + correction: 0, + other: 0, + }, + }, + thread_target: { + type: "choice", + choice: threadId, + confidence: 0.75, + probabilities: { [threadId]: 0.84, new: 0.06, none: 0.1 }, + }, + significance: { + type: "score", + score: 2.26, + confidence: 0.69, + legend: { "0": "none", "1": "low", "2": "normal", "3": "high" }, + probabilities: { "0": 0.01, "1": 0.02, "2": 0.69, "3": 0.28 }, + }, + }, + candidates: [{ + quote, + start: 0, + end: quote.length, + answers: { + role: { + type: "choice", + choice: "option", + confidence: 0.87, + probabilities: { + question: 0, + option: 0.89, + reason: 0, + constraint: 0, + support: 0.05, + objection: 0, + resolution: 0.05, + reopening: 0, + none: 0.01, + }, + }, + thread: { + type: "choice", + choice: threadId, + confidence: 0.95, + probabilities: { [threadId]: 0.97, new: 0.03, none: 0 }, + }, + option: { + type: "choice", + choice: lexicalId, + confidence: 1, + probabilities: Object.fromEntries( + options.map(option => [option.id, option.id === lexicalId ? 1 : 0]) + .concat([["new", 0], ["none", 0]]), + ), + }, + chosen_option: { + type: "choice", + choice: lexicalId, + confidence: 0.98, + probabilities: Object.fromEntries( + options.map(option => [option.id, option.id === lexicalId ? 0.98 : 0]) + .concat([["new", 0], ["none", 0.02]]), + ), + }, + relation: { + type: "choice", + choice: "supports", + confidence: 0.66, + probabilities: { + supports: 0.73, + challenges: 0.05, + qualifies: 0.13, + replaces: 0.04, + unrelated: 0.05, + }, + }, + new_option: { type: "noul", noul: 0.85 }, + planning_substance: { type: "noul", noul: 0.51 }, + support: { type: "noul", noul: 0.92 }, + objection: { type: "noul", noul: 0.05 }, + duplicate: { type: "noul", noul: 0.15 }, + explicit_resolution: { type: "noul", noul: 0.1 }, + reopening: { type: "noul", noul: 0.06 }, + material_objection: { type: "noul", noul: 0.1 }, + }, + }], + }); + + expect(state.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(state.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); + expect(linkedCards.get(threadId)?.options).toEqual(options); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts new file mode 100644 index 00000000..78fe697e --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("D01 m6 proposes a spike-scoped Save for existing linked Lexical", () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let current = message( + "d01-m6", + `agreed. ${quote} still want to see how it handles pasted tables.`, + "Mei", + ); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.88, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.95, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.73, + probabilities: { + question: 0, + proposal: 0.78, + evaluation: 0.2, + commitment: 0.02, + correction: 0, + other: 0, + }, + }, + thread_target: { + type: "choice", + choice: threadId, + confidence: 0.75, + probabilities: { [threadId]: 0.84, new: 0.06, none: 0.1 }, + }, + significance: { + type: "score", + score: 2.26, + confidence: 0.69, + legend: { "0": "none", "1": "low", "2": "normal", "3": "high" }, + probabilities: { "0": 0.01, "1": 0.02, "2": 0.69, "3": 0.28 }, + }, + }, + candidates: [{ + quote, + start: 8, + end: 39, + answers: { + ...follow({ role: "support", thread: threadId }), + role: { + type: "choice", + choice: "support", + confidence: 0.87, + probabilities: { + question: 0, + option: 0.05, + reason: 0, + constraint: 0, + support: 0.89, + objection: 0, + resolution: 0.05, + reopening: 0, + none: 0.01, + }, + }, + option: confidentChoice(lexicalId), + chosen_option: confidentChoice(lexicalId), + relation: confidentChoice("supports"), + new_option: { type: "noul", noul: 0.85 }, + planning_substance: { type: "noul", noul: 0.51 }, + support: { type: "noul", noul: 0.92 }, + duplicate: { type: "noul", noul: 0.15 }, + }, + }], + }); + let scoped = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.proposed" + ); + + expect(scoped).toHaveLength(1); + expect(scoped[0]).toMatchObject({ + type: "scoped-choice.proposed", + id: expect.any(String), + threadId, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: lexicalId, + label: "Lexical", + scope: "spike", + source: { + messageId: current.id, + author: { kind: "member", handle: "Mei" }, + quote, + start: 8, + end: 39, + role: "support", + }, + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(state.threads[0]!.pendingSettle).toBeUndefined(); + expect(state.threads[0]!.decisionHistory).toEqual([]); + expect(linkedCards.get(threadId)?.options).toEqual(options); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts new file mode 100644 index 00000000..4cd9e5ae --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("D01 m7 cannot agree from quoted, self, mismatched, or unpending evidence", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let cases: Array<{ + name: string; + state: ConversationPlan.State; + owned?: number; + author?: string; + text?: string; + optionId?: string; + }> = [ + { + name: "low ownership", + state: pending.state, + owned: 0.2, + }, + { + name: "quoted affirmation", + state: pending.state, + text: 'Nia said: "yep, Lexical for the spike."', + owned: 0.89, + }, + { + name: "the proposer repeats their own preference", + state: pending.state, + author: "Mei", + }, + { + name: "another option is named", + state: pending.state, + text: "yep, Tiptap for the spike. not a final library call yet.", + optionId: linked.options[0]!.id, + }, + { + name: "another scope is named", + state: pending.state, + text: "yep, Lexical for the final call. not a final library call yet.", + }, + { + name: "there is no pending proposal", + state: linked.state, + }, + ]; + + for (let [index, item] of cases.entries()) { + let { result } = d01RobM7PolicyResult(linked, { + state: item.state, + owned: item.owned, + author: item.author, + text: item.text, + optionId: item.optionId, + messageId: `d01-m7-negative-${index}`, + }); + expect( + result.events.filter(event => (event as { type: string }).type === "scoped-choice.agreed"), + ).toEqual([]); + expect(result.events.filter(event => event.type === "settle.agreed")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts new file mode 100644 index 00000000..69ace7bf --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("a new stance links scoped support only with a confident owned target", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let withNamedOption = applyEvent(pending.state, { + id: "scoped-stance-named-option", + type: "option.added", + threadId: linked.threadId, + observedThreadVersion: pending.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1002, + contribution: { id: pending.lexicalId, text: "Lexical", authoring: "human-edited" }, + }); + let accepted = d01RobM7PolicyResult(linked, { state: pending.state }); + let agreement = accepted.result.events.find(event => event.type === "scoped-choice.agreed"); + if (!agreement) throw new Error("scoped agreement missing"); + let withAgreement = applyInference(pending.state, agreement, accepted.current); + let cases = [ + { + name: "proposer with exact target", + state: pending.state, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "proposer with exact named option", + state: withNamedOption, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + chosenOption: pending.lexicalId, + expectedOptionId: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "proposer with mismatched chosen option", + state: withNamedOption, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + chosenOption: "none", + expectedOptionId: pending.lexicalId, + expected: null, + }, + { + name: "agreeing supporter with exact target", + state: withAgreement, + speaker: "Rob", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "non-supporter with exact target", + state: pending.state, + speaker: "Nia", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: null, + }, + { + name: "proposer with unrelated objection", + state: pending.state, + speaker: "Mei", + quote: "I oppose making the toolbar blue.", + option: "none", + expected: null, + }, + ]; + for (let [index, item] of cases.entries()) { + let current = message(`scoped-stance-${index}`, item.quote, item.speaker); + let result = planEvents({ + channelId: "channel", + message: current, + state: item.state, + linkedCards: linked.linkedCards, + first: { + ...first({ c0_owned_unretracted: 0.98 }), + act: confidentChoice("evaluation"), + thread_target: confidentChoice(linked.threadId), + }, + candidates: [{ + quote: item.quote, + start: 0, + end: item.quote.length, + answers: { + ...follow({ role: "objection", thread: linked.threadId }), + option: confidentChoice(item.option), + chosen_option: confidentChoice(item.chosenOption ?? item.option), + }, + }], + }); + let stances = result.events.filter(event => event.type === "stance.changed"); + expect(stances, item.name).toHaveLength(1); + expect(stances[0], item.name).toMatchObject({ + type: "stance.changed", + optionId: item.expectedOptionId, + scopedProposalId: item.expected, + }); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-standalone.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-standalone.test.ts new file mode 100644 index 00000000..944f10c6 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-standalone.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("a standalone Lexical spike preference opens a scoped Save and keeps the editor card open", async () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let text = "I’d pick Lexical for the spike"; + let current = message("standalone-lexical-spike", text, "Mei"); + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + linkedCards, + ask: async request => { + if ("new_question" in request.questions) { + return mockResult(request.questions, { + new_question: 0.1, + new_option: 0.89, + act: "proposal", + thread_target: threadId, + significance: 2, + support: 0.92, + explicit_resolution: 0.1, + }); + } + let overrides: Record = {}; + for (let key of Object.keys(request.questions)) { + if (key.endsWith("_role")) overrides[key] = "support"; + if (key.endsWith("_thread")) overrides[key] = threadId; + if (/^c\d+_option$/.test(key) || key.endsWith("_chosen_option")) { + overrides[key] = lexicalId; + } + if (key.endsWith("_relation")) overrides[key] = "supports"; + if (key.endsWith("_support")) overrides[key] = 0.92; + if (key.endsWith("_new_option")) overrides[key] = 0.85; + if (key.endsWith("_planning_substance")) overrides[key] = 0.75; + if (key.endsWith("_duplicate")) overrides[key] = 0.05; + } + return mockResult(request.questions, overrides); + }, + }); + let staged = output.events.reduce((next, event) => applyInference(next, event, current), state); + expect(output.events.map(event => event.type)).toEqual(["scoped-choice.proposed"]); + expect(output.events[0]).toMatchObject({ + type: "scoped-choice.proposed", + threadId, + optionId: lexicalId, + label: "Lexical", + scope: "spike", + source: { + messageId: current.id, + quote: text, + start: 0, + end: text.length, + role: "support", + }, + }); + expect(staged.threads[0]!.status).toBe("exploring"); + expect(staged.threads[0]!.pendingScopedChoice).toMatchObject({ + optionId: lexicalId, + label: "Lexical", + scope: "spike", + }); + expect(staged.threads[0]!.decisionHistory).toEqual([]); + expect(staged.threads[0]!.pendingSettle).toBeUndefined(); + expect(staged.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); + expect(linkedCards.get(threadId)?.options).toEqual(options); +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle-1.test.ts b/apps/server/src/conversation-plan/pipeline-lifecycle-1.test.ts new file mode 100644 index 00000000..cd5c6fa3 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle-1.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { planEvents } from "./policy"; +import { buildTargetingRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; +import { decided, discarded } from "./pipeline-lifecycle.test-fixtures"; + +test("a discarded question opens anew only when deliberately raised again", () => { + let state = discarded(seeded()); + let current = message("bring-back", "Should we use an optional outline?"); + let request = buildTargetingRequest(current, [], state.threads, extractQuotes(current.text)); + let choices = (request.questions.c0_thread as Extract).criteria; + expect(choices["thread-a"]).toContain("(discarded)"); + let ask = (raises: number, newQuestion = 0.95) => + planEvents({ + channelId: "channel", + message: current, + state, + first: first({ new_question: newQuestion }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "question", thread: "thread-a" }), + raises_again: { type: "noul", noul: raises }, + }, + }], + }); + expect(ask(0.2).events).toEqual([]); + expect(ask(0.2).outcomes[0].gate).toBe("discarded question not raised again"); + expect(ask(0.9, 0.89).events).toEqual([]); + let raised = ask(0.9); + expect(raised.events.map((event) => event.type)).toEqual(["thread.opened"]); + expect(raised.events[0].threadId).not.toBe("thread-a"); + expect(state.threads[0].status).toBe("discarded"); + let option = message("late-option", "Use a summary instead."); + let blocked = planEvents({ + channelId: "channel", + message: option, + state, + first: first({ new_option: 0.9 }), + candidates: [{ + quote: option.text, + start: 0, + end: option.text.length, + answers: follow({ role: "option", thread: "thread-a" }), + }], + }); + expect(blocked.events).toEqual([]); + expect(blocked.outcomes[0].gate).toBe("thread discarded"); +}); + +test("a decided target cannot receive a settle proposal", () => { + let current = message("closed-settle", "Let's go with the optional outline."); + let output = planEvents({ + channelId: "channel", + message: current, + state: decided(), + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0].gate).toBe("settle target needs review"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts b/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts new file mode 100644 index 00000000..1b05da3c --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { decided } from "./pipeline-lifecycle.test-fixtures"; + +test("material concern and direct request only propose reopening diagnostics", () => { + let state = decided(); + let objection = message("m3", "The blank link is too easy to miss."); + let review = planEvents({ + channelId: "channel", + message: objection, + state, + first: first({ objection: 0.9 }), + candidates: [ + { + quote: objection.text, + start: 0, + end: objection.text.length, + answers: follow({ role: "objection", thread: "thread-a", material_objection: 0.92 }), + }, + ], + }); + expect(review.events.map((event) => event.type)).toContain("candidate.proposed"); + let reopen = message("m4", "Reopen the outline decision."); + let direct = planEvents({ + channelId: "channel", + message: reopen, + state, + first: first({ reopening: 0.98 }), + candidates: [ + { + quote: reopen.text, + start: 0, + end: reopen.text.length, + answers: follow({ role: "reopening", thread: "thread-a", reopening: 0.98 }), + }, + ], + }); + expect(direct.events.map((event) => event.type)).toEqual(["candidate.proposed"]); + expect(direct.events[0]).toMatchObject({ candidate: { kind: "reopening" } }); + expect(state.threads[0].status).toBe("decided"); + expect(state.threads[0].decisionHistory).toHaveLength(1); +}); + +test("a .69 material concern proposes one human reopening review for a decided thread", () => { + let state = decided(); + let current = message( + "material", + "I'm worried the blank link is hidden. A newcomer may miss it.", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ objection: 0.58 }), + significance: { + type: "score", + score: 1.83, + confidence: 0.8, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.17, "2": 0.83, "3": 0 }, + }, + }, + candidates: quotes.map((quote) => ({ + ...quote, + answers: follow({ role: "objection", thread: "thread-a", material_objection: 0.69 }), + })), + }); + expect(output.events.map((event) => event.type)).toEqual(["candidate.proposed"]); + expect(output.outcomes).toHaveLength(2); + expect(output.outcomes[0].status).toBe("review"); + expect(output.outcomes[1].gate).toContain("already proposed"); + expect(state.threads[0].status).toBe("decided"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle-3.test.ts b/apps/server/src/conversation-plan/pipeline-lifecycle-3.test.ts new file mode 100644 index 00000000..afdf3821 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle-3.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { applyEvent } from "./events"; +import { planEvents } from "./policy"; +import { interpretMessage } from "./interpret"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; +import { member, message } from "./policy-initial.test-fixtures"; +import { mockResult, seeded } from "./interpret.test-fixtures"; +import { decided, discarded } from "./pipeline-lifecycle.test-fixtures"; + +test("D01 recovery does not reopen the identical discarded question without a re-raise", () => { + let input = d01RecordedOpening(); + let earlier = message("d01-earlier", input.message.text, "Nia"); + let prior = applyInference(initialState(), { + id: "d01-earlier-open", + type: "thread.opened", + threadId: "d01-earlier-thread", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: earlier.ts, + source: { + messageId: earlier.id, + author: earlier.author as ConversationPlan.SourceAuthor, + quote: earlier.text, + start: 0, + end: earlier.text.length, + role: "question", + }, + question: earlier.text, + }, earlier); + let discarded = applyEvent(prior, { + id: "d01-earlier-discard", + type: "thread.discarded", + threadId: "d01-earlier-thread", + observedThreadVersion: prior.threads[0]!.version, + origin: "human", + actor: member("Nia"), + at: earlier.ts + 1, + }); + let repeated = { + ...input, + message: { ...input.message, id: "d01-repeated", ts: earlier.ts + 2 }, + state: discarded, + }; + expect(planEvents(repeated).events).toEqual([]); +}); + +test("multi-option grouping does not revive a discarded thread", async () => { + let state = discarded(seeded()); + let current = message("discarded-options", "Use Auth0. Use custom login."); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { new_question: 0.97, act: "question", thread_target: "new", significance: 2 } + : { + c0_role: "option", + c0_thread: "thread-a", + c0_new_option: 0.95, + c1_role: "option", + c1_thread: "thread-a", + c1_new_option: 0.95, + }, + ), + }); + expect(output.events).toEqual([]); +}); + +test("ignored chatter leaves accepted earlier decisions intact", async () => { + let state = decided(); + let output = await interpretMessage({ + channelId: "channel", + message: message("m6", "I'll check tomorrow."), + recent: [], + state, + ask: async (request) => mockResult(request.questions, {}), + }); + expect(output.events).toEqual([]); + expect(state.threads[0].decisionHistory).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts new file mode 100644 index 00000000..1132d916 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts @@ -0,0 +1,31 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import { member } from "./policy-initial.test-fixtures"; +import { seeded } from "./interpret.test-fixtures"; + +export function decided(): ConversationPlan.State { + let state = seeded(); + return applyEvent(state, { + id: "card:decision", + type: "decision.recorded", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "human", + actor: member("Ana"), + at: 1000, + text: "Use an optional outline", + explicit: true, + }); +} + +export function discarded(state: ConversationPlan.State): ConversationPlan.State { + return applyEvent(state, { + id: "card:discarded", + type: "thread.discarded", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: member("Ana"), + at: 1001, + }); +} diff --git a/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts b/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts new file mode 100644 index 00000000..20d7efe6 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { optionIdFor, planEvents } from "./policy"; +import { seeded, withOption } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +// Complete original archive pipeline.test.ts callbacks; shared helpers remain unchanged. +test("owned exact resolution mirrors a linked card option and suggests it atomically", () => { + let current = message("m-card", "Let's just go with the VPS option."); + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let id = optionIdFor("channel", "card-option", 0, 1000); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: [{ id, label: "VPS option" }], + }]]); + let input = { + channelId: "channel", + message: current, + state, + linkedCards: cards, + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: confidentChoice(id), + }, + }], + }; + let result = planEvents(input); + expect(result.events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + expect(result.events[0]).toMatchObject({ + contribution: { id, text: "VPS option", authoring: "scribe" }, + }); + expect(result.events[1]).toMatchObject({ optionId: id, source: { quote: current.text } }); + let mixedRole = { + ...input, + first: { ...input.first, act: confidentChoice("commitment") }, + candidates: [{ + ...input.candidates[0]!, + answers: { + ...input.candidates[0]!.answers, + role: { + type: "choice" as const, + choice: "resolution", + confidence: 0.72, + probabilities: { resolution: 0.72, option: 0.2, none: 0.08 }, + }, + }, + }], + }; + expect(planEvents(mixedRole).events.map(event => event.type)) + .toEqual(["option.added", "settle.suggested"]); + let recommendation = message("recommend", "We should use the VPS option."); + expect( + planEvents({ + ...mixedRole, + message: recommendation, + first: { ...input.first, act: confidentChoice("proposal") }, + candidates: [{ + ...mixedRole.candidates[0]!, + quote: recommendation.text, + end: recommendation.text.length, + answers: { + ...mixedRole.candidates[0]!.answers, + support: { type: "noul" as const, noul: 0.95 }, + }, + }], + }).events.map(event => event.type), + ).toEqual(["option.added", "settle.suggested"]); + let mirrored = withOption(state); + mirrored.threads[0]!.contributions[0]!.id = id; + expect(planEvents({ ...input, state: mirrored }).events.map(event => event.type)) + .toEqual(["settle.suggested"]); + expect( + planEvents({ + ...input, + linkedCards: new Map([["thread-a", { + cardId: "other-card", + options: [{ id, label: "VPS option" }], + }]]), + }).events, + ).toEqual([]); + expect(planEvents({ ...input, first: first({ c0_owned_unretracted: 0.4 }) }).events) + .toEqual([]); + let agent = { ...current, author: { kind: "agent" as const } }; + expect(planEvents({ ...input, message: agent }).events).toEqual([]); + expect( + planEvents({ + ...input, + candidates: [{ + ...input.candidates[0]!, + answers: { + ...input.candidates[0]!.answers, + chosen_option: { + type: "choice" as const, + choice: id, + confidence: 0.6, + probabilities: { [id]: 0.6, none: 0.4 }, + }, + }, + }], + }).events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-linked-card.test.ts b/apps/server/src/conversation-plan/pipeline-linked-card.test.ts new file mode 100644 index 00000000..fe6845cf --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-card.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { optionIdFor, planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { buildCandidateTargetingRequest } from "./questions"; +import { seeded } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +// Complete original archive pipeline.test.ts callbacks; shared helpers remain unchanged. +test("targeting lists all ten current linked card options, including beyond thread truncation", () => { + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let ids = Array.from( + { length: 10 }, + (_, index) => optionIdFor("channel", `card-${index}`, 0, 1000), + ); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: ids.map((id, index) => ({ id, label: `Visible choice ${index}` })), + }]]); + let current = message("choice", "Let's go with visible choice nine."); + let request = buildCandidateTargetingRequest( + current, + [], + state.threads, + extractQuotes(current.text), + 0, + state.events, + cards, + ); + let criteria = + (request.questions.c0_chosen_option as Extract).criteria; + expect(ids.every(id => id in criteria)).toBe(true); + expect(criteria[ids[9]!]).toContain("Visible choice 9"); + expect( + (request.state as { threads: Array<{ options: Array<{ id: string }> }> }).threads[0]!.options, + ) + .toHaveLength(10); +}); + +test("two distinct linked card choices in one message cannot create competing Save prompts", () => { + let current = message("competing", "Let's go with VPS. Let's go with managed hosting."); + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let vps = optionIdFor("channel", "vps", 0, 1000); + let managed = optionIdFor("channel", "managed", 0, 1000); + let quotes = extractQuotes(current.text); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: [{ id: vps, label: "VPS" }, { id: managed, label: "Managed hosting" }], + }]]); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards: cards, + first: { ...first({ explicit_resolution: 0.98 }), act: confidentChoice("commitment") }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: confidentChoice(index === 0 ? vps : managed), + }, + })), + }); + expect(result.events).toEqual([]); + expect(result.outcomes.every(item => item.gate === "competing choice needs review")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts new file mode 100644 index 00000000..d1b9fc8d --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts @@ -0,0 +1,22 @@ +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; + +export function d01LinkedEditorCard() { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let threadId = state.threads[0]!.id; + let cardId = "01M3QAQ8HM8DVYA9E4N28QCQ7T"; + state.threads[0]!.questionnaireId = cardId; + let options = [ + { id: "01M3QAQWM9QHKKX1YV7EEYVX90", label: "Tiptap" }, + { id: "01M3QAQWMTWFQQNS5DNDG66X4N", label: "Bare ProseMirror" }, + { id: "01M3QAQWN9TYWMFW3D0EYAZY8H", label: "Lexical" }, + { + id: "01M3QAQWNRJC3B4P6TD2HHBYKS", + label: "Native Selection and Range with a custom document model", + }, + ]; + let linkedCards = new Map([[threadId, { cardId, options }]]); + return { state, threadId, options, linkedCards }; +} diff --git a/apps/server/src/conversation-plan/pipeline-option-1.test.ts b/apps/server/src/conversation-plan/pipeline-option-1.test.ts new file mode 100644 index 00000000..c0415c33 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-1.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test("triage corroboration can accept a useful option with uncertain role, while vague chatter stays unlinked", () => { + let state = seeded(); + let current = message("optional", "Keep setup optional."); + let option = follow({ role: "option", thread: "thread-a" }); + option.role = { + type: "choice", + choice: "option", + confidence: 0.56, + probabilities: { option: 0.56, question: 0.42, none: 0.02 }, + }; + let useful = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.91 }), + significance: { + type: "score", + score: 2.4, + confidence: 0.8, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0, "2": 0.6, "3": 0.4 }, + }, + }, + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers: option }], + }); + expect(useful.events.map((event) => event.type)).toEqual(["option.added"]); + let vague = message("vague", "That sounds like a lot for the first hour."); + let objection = follow({ role: "objection", thread: "thread-a" }); + let ignored = planEvents({ + channelId: "channel", + message: vague, + state, + first: { + ...first({ objection: 0.52, evidence: 0.6 }), + significance: { + type: "score", + score: 0.65, + confidence: 0.9, + legend: { "0": "chatter", "1": "minor" }, + probabilities: { "0": 0.35, "1": 0.65 }, + }, + }, + candidates: [{ quote: vague.text, start: 0, end: vague.text.length, answers: objection }], + }); + expect(ignored.events).toEqual([]); +}); + +test("one strong option quote cannot authorize another quote's weak option label", () => { + let state = seeded(); + let current = message( + "mixed-role", + "Start with an optional outline. I'll check the draft tomorrow.", + ); + let quotes = extractQuotes(current.text); + let weakOption = { + ...follow({ role: "option", thread: "thread-a" }), + role: { + type: "choice" as const, + choice: "option", + confidence: 0.56, + probabilities: { option: 0.56, none: 0.42, question: 0.02 }, + }, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95 }), + significance: { + type: "score", + score: 2, + confidence: 1, + legend: { "0": "chatter", "1": "minor", "2": "useful" }, + probabilities: { "0": 0, "1": 0, "2": 1 }, + }, + }, + candidates: [ + { ...quotes[0], answers: { ...weakOption, new_option: { type: "noul", noul: 0.95 } } }, + { ...quotes[1], answers: { ...weakOption, new_option: { type: "noul", noul: 0.05 } } }, + ], + }); + expect(result.events.map((event) => event.type)).toEqual(["option.added"]); + expect(result.outcomes[1]).toMatchObject({ status: "ignored", start: quotes[1].start }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-option-2.test.ts b/apps/server/src/conversation-plan/pipeline-option-2.test.ts new file mode 100644 index 00000000..9cbf515f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-2.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; +import { discarded } from "./pipeline-lifecycle.test-fixtures"; + +test("a new option remains visible when its wording also sounds like a resolution", () => { + let state = withOption(seeded()); + let current = message( + "new-access-path", + "I guess both. Let's do no harness, just lightweight stuff with API keys for now.", + ); + let quote = "Let's do no harness, just lightweight stuff with API keys for now."; + let start = current.text.indexOf(quote); + let answers = { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.8 }), + role: { + type: "choice" as const, + choice: "resolution", + confidence: 0.44, + probabilities: { resolution: 0.49, option: 0.42, none: 0.09 }, + }, + chosen_option: optionChoice("new"), + new_option: { type: "noul" as const, noul: 0.91 }, + planning_substance: { type: "noul" as const, noul: 0.88 }, + duplicate: { type: "noul" as const, noul: 0.11 }, + }; + let triage = { + ...first({ new_option: 0.86, explicit_resolution: 0.72 }), + significance: { + type: "score" as const, + score: 2.86, + confidence: 0.87, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0, "2": 0.14, "3": 0.86 }, + }, + }; + let assess = ( + candidateAnswers = answers as Record, + firstAnswers = triage as Record, + candidateState = state, + ) => + planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: firstAnswers, + candidates: [{ quote, start, end: start + quote.length, answers: candidateAnswers }], + }); + let accepted = assess(); + expect(accepted.events.map((event) => event.type)).toEqual(["option.added"]); + expect(accepted.events[0]).toMatchObject({ + threadId: "thread-a", + source: { quote, start, end: start + quote.length, role: "option" }, + contribution: { text: quote, targetId: "thread-a" }, + }); + expect(accepted.events.some((event) => event.type === "settle.suggested")).toBe(false); + expect(assess({ ...answers, chosen_option: optionChoice(seededOptionId) }).events) + .toEqual([]); + expect(assess({ ...answers, new_option: { type: "noul", noul: 0.6 } }).events) + .toEqual([]); + expect(assess({ ...answers, duplicate: { type: "noul", noul: 0.9 } }).events) + .toEqual([]); + expect(assess(answers, { ...triage, new_option: { type: "noul", noul: 0.6 } }).events) + .toEqual([]); + expect(assess(answers, { ...triage, c0_owned_unretracted: { type: "noul", noul: 0.3 } }).events) + .toEqual([]); + expect(assess({ ...answers, thread: optionChoice("none") }).events).toEqual([]); + expect(assess(answers, triage, discarded(state)).events).toEqual([]); +}); + +test("one message can agree with a pending choice and open a separate sourced question", () => { + let current = message( + "combined", + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + "alice", + ); + let state = settledBy(seeded(), "bob"); + let quotes = extractQuotes(current.text); + let candidates = quotes.map((quote, index) => ({ + ...quote, + answers: index === 0 + ? { + ...follow({ role: "support", thread: "thread-a" }), + support: { type: "noul" as const, noul: 0.8 }, + agrees_with_settle: { type: "noul" as const, noul: 0.8 }, + } + : index === 1 + ? follow({ role: "question", thread: "new" }) + : follow({ role: "none", thread: "none" }), + })); + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ new_question: 0.95, support: 0.9 }), + candidates, + }); + expect(result.events.map(event => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + "thread.opened", + ]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(result.events.map(event => "source" in event ? event.source?.quote : undefined)) + .toEqual([quotes[0]?.quote, quotes[0]?.quote, quotes[1]?.quote]); + let uncertain = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ new_question: 0.95, support: 0.9 }), + candidates: [{ + ...candidates[0]!, + answers: { + ...candidates[0]!.answers, + thread: follow({ role: "support", thread: "none" }).thread, + }, + }, ...candidates.slice(1)], + }); + expect(uncertain.events.map(event => event.type)).toEqual(["thread.opened"]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-option-3.test.ts b/apps/server/src/conversation-plan/pipeline-option-3.test.ts new file mode 100644 index 00000000..822dcfa4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-3.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import type { Chat } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import { optionIdFor, planEvents } from "./policy"; +import { seeded, seededOptionId, settledBy } from "./interpret.test-fixtures"; +import { first, follow, member, message } from "./policy-initial.test-fixtures"; + +test("support for another option is not agreement despite a high generic score", () => { + let state = settledBy(seeded(), "bob"); + let otherOptionId = optionIdFor("channel", "other-option", 0, 1000); + state = applyEvent(state, { + id: "human-other-option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: member("alice"), + at: 1001, + contribution: { + id: otherOptionId, + text: "Use a required outline.", + authoring: "human-edited", + }, + }); + let current = message("other-support", "I support the required outline.", "alice"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + option: { + type: "choice", + choice: otherOptionId, + confidence: 0.95, + probabilities: { [otherOptionId]: 0.95, [seededOptionId]: 0.05 }, + }, + agrees_with_settle: { type: "noul", noul: 0.99 }, + }, + }], + }); + expect(output.events.map((event) => event.type)).toEqual(["stance.changed"]); + expect(output.events[0]).toMatchObject({ optionId: otherOptionId }); + expect(state.threads[0].pendingSettle?.optionId).toBe(seededOptionId); +}); + +test("weak, self, agent, negative, and wrong-role evidence cannot agree", () => { + let state = settledBy(seeded(), "bob"); + for ( + let [id, text, handle, role, agreement] of [ + ["weak", "Sounds good to me.", "alice", "support", 0.5], + ["self", "Sounds good to me.", "bob", "support", 0.95], + ["negative", "I don't agree with that.", "alice", "none", 0.05], + ["disagree", "I disagree with that.", "alice", "none", 0.05], + ["reported", "She said it sounds good.", "alice", "none", 0.05], + ["quoted", "Please quote ‘sounds good to me’.", "alice", "none", 0.05], + ["sarcastic", "Sure 🙄", "alice", "none", 0.05], + ["objection", "I object to that.", "alice", "objection", 0.95], + ] as const + ) { + let current = message(id, text, handle); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.9 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role, thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: agreement }, + }, + }], + }); + expect(output.events.map((event) => event.type)).not.toContain("settle.agreed"); + } + let agentMessage: Chat.Entry = { + ...message("agent-assent", "Sounds good to me."), + author: { kind: "agent" }, + }; + let agent = planEvents({ + channelId: "channel", + message: agentMessage, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: agentMessage.text, + start: 0, + end: agentMessage.text.length, + answers: { + ...follow({ role: "none", thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.95 }, + }, + }], + }); + expect(agent.events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-option-4.test.ts b/apps/server/src/conversation-plan/pipeline-option-4.test.ts new file mode 100644 index 00000000..cd930596 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-4.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import type { Chat } from "@chopin/protocol"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("the Planner can quote an option but cannot propose a settle", () => { + let state = withOption(seeded()); + let current: Chat.Entry = { + ...message("agent-settle", "Let's go with the optional outline."), + author: { kind: "agent" }, + }; + let base = { + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + }; + let settle = planEvents({ + ...base, + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(settle.events).toEqual([]); + let option = { ...current, id: "agent-option", text: "Try a managed provider." }; + let quoted = planEvents({ + ...base, + message: option, + first: first({ new_option: 0.98 }), + candidates: [{ + quote: option.text, + start: 0, + end: option.text.length, + answers: follow({ role: "option", thread: "thread-a" }), + }], + }); + expect(quoted.events.map((event) => event.type)).toEqual(["option.added"]); + expect(quoted.events[0]).toMatchObject({ origin: "planner" }); + let addition = quoted.events[0]; + expect(addition.type === "option.added" && addition.contribution.id) + .toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/); +}); + +test("a settle with no known chosen option stays in review", () => { + let current = message("new-choice", "Let's just go with something new."); + let output = planEvents({ + channelId: "channel", + message: current, + state: withOption(seeded()), + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice("new"), + }, + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0]).toMatchObject({ + status: "review", + gate: "chosen option needs review", + }); +}); + +test("negated proposals cannot suggest a rejected option", () => { + let state = withOption(seeded()); + let current = message("negated", "We've decided not to use the optional outline."); + for (let chosen of ["none", seededOptionId]) { + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + option: optionChoice(seededOptionId), + chosen_option: optionChoice(chosen), + }, + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0].gate).toBe("chosen option needs review"); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-option-5.test.ts b/apps/server/src/conversation-plan/pipeline-option-5.test.ts new file mode 100644 index 00000000..1a6d6074 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-5.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("an unclear choice is held, but current opposition does not block a clear suggestion", () => { + let state = withOption(seeded()); + let opposition = message("oppose-option", "I oppose the optional outline.", "Theo"); + state = applyInference(state, { + id: "opposition-option", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: opposition.id, + author: opposition.author as ConversationPlan.SourceAuthor, + quote: opposition.text, + start: 0, + end: opposition.text.length, + role: "objection", + }, + optionId: seededOptionId, + position: "oppose", + }, opposition); + let current = message("uncertain-choice", "Let's go with the optional outline."); + let input = { + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice("none"), + }, + }], + }; + let unclear = planEvents(input); + expect(unclear.events).toEqual([]); + expect(unclear.outcomes[0].gate).toBe("chosen option needs review"); + let clear = planEvents({ + ...input, + candidates: [{ + ...input.candidates[0], + answers: { + ...input.candidates[0].answers, + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(clear.events.map((event) => event.type)).toEqual(["settle.suggested"]); +}); + +test("proposal, assent, quote, sarcasm, and reported consensus do not decide", () => { + let state = seeded(); + for ( + let [id, text] of [ + ["proposal", "Maybe use an optional outline."], + ["assent", "+1 to the optional outline."], + ["quote", "Please show ‘we decided to use an outline’ in history."], + ["sarcasm", "Sure, force everyone through a template 🙄"], + ["reported", "Everyone seems to agree on an outline, I guess."], + ] + ) { + let current = message(id, text); + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.05 }), + candidates: [ + { + quote: text, + start: 0, + end: text.length, + answers: follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.04 }), + }, + ], + }); + expect(result.events).toEqual([]); + } +}); + +test("a settle needs strong first-pass and candidate evidence", () => { + let state = withOption(seeded()); + let current = message("resolution", "We've decided to use the optional outline."); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.79 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0].gate).toBe("settle authority unclear"); + let weakCandidate = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.84 }), + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(weakCandidate.events).toEqual([]); + expect(weakCandidate.outcomes[0].gate).toBe("settle authority unclear"); + expect(state.threads[0].status).toBe("exploring"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-ordinary-save-competing.test.ts b/apps/server/src/conversation-plan/pipeline-ordinary-save-competing.test.ts new file mode 100644 index 00000000..b5185e36 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-ordinary-save-competing.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { extractQuotes } from "./quotes"; +import { message } from "./policy-initial.test-fixtures"; +import { + emailState, + knownChoiceAnswers, + mockInterpret, + newChoiceAnswers, + proposalTriage, +} from "./pipeline-ordinary-save.test-fixtures"; +import { queueWithPendingSave } from "./pipeline-ordinary-save-pending.test-fixtures"; + +// Whole archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 pipeline.test.ts callbacks. +test("distinct new choices for one thread add sourced options without choosing between them", async () => { + let current = message( + "competing-same-thread", + "We should use Postmark for notification emails. We should use Amazon SES for notification emails.", + ); + let quotes = extractQuotes(current.text); + let output = await mockInterpret( + current, + emailState(), + proposalTriage("email-thread"), + prefix => newChoiceAnswers(prefix, "email-thread"), + ); + expect(output.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(output.events.map(event => event.type === "option.added" ? event.source?.quote : "")) + .toEqual(quotes.map(item => item.quote)); + for (let [index, quote] of quotes.entries()) { + expect(output.events[index]).toMatchObject({ + threadId: "email-thread", + contribution: { text: quote.quote }, + source: { quote: quote.quote, start: quote.start, end: quote.end, role: "option" }, + }); + } + expect(output.analysis.outcomes?.[1]).toMatchObject({ + start: quotes[1]!.start, + end: quotes[1]!.end, + status: "review", + }); +}); + +test("a pending Save survives later competing known and new choices", async () => { + let { state, proposal } = queueWithPendingSave(); + for ( + let item of [ + { + id: "later-known-choice", + text: "I think we should use the PostgreSQL queue.", + newChoice: false, + optionId: "postgres-option", + }, + { + id: "later-new-choice", + text: "We should use BullMQ for background jobs.", + newChoice: true, + optionId: "new", + }, + ] as const + ) { + let current = message(item.id, item.text, "Omar"); + let triage = proposalTriage("queue-thread", "proposal", { + new_option: item.newChoice ? 0.98 : 0.1, + }); + let output = await mockInterpret( + current, + state, + triage, + prefix => + item.newChoice + ? newChoiceAnswers(prefix, "queue-thread") + : knownChoiceAnswers(prefix, "queue-thread", item.optionId), + ); + expect(output.events).toEqual([]); + expect(output.analysis.outcomes?.[0]).toMatchObject({ + start: 0, + end: current.text.length, + status: "review", + }); + let next = output.events.reduce( + (currentState, event) => applyInference(currentState, event, current), + state, + ); + expect(next.threads.find(thread => thread.id === "queue-thread")?.pendingSettle) + .toEqual({ optionId: "sqs-option", proposer: "Mina", messageId: proposal.id }); + } +}); + +test("repeating the same new choice twice opens one Save suggestion", async () => { + let text = "We should use Postmark for notification emails."; + let current = message("repeat-same-choice", `${text} ${text}`); + let quotes = extractQuotes(current.text); + let output = await mockInterpret( + current, + emailState(), + proposalTriage("email-thread"), + prefix => newChoiceAnswers(prefix, "email-thread"), + ); + expect(quotes.map(item => item.quote)).toEqual([text, text]); + expect(output.events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + let added = output.events[0]; + let selectedSource = added && "source" in added ? added.source : undefined; + expect(added).toMatchObject({ + type: "option.added", + threadId: "email-thread", + contribution: { text }, + }); + expect( + quotes.some(quote => + quote.start === selectedSource?.start && quote.end === selectedSource?.end + && quote.quote === selectedSource?.quote + ), + ).toBe(true); + expect(output.events[1]).toMatchObject({ + type: "settle.suggested", + optionId: added?.type === "option.added" ? added.contribution.id : undefined, + source: { + quote: selectedSource?.quote, + start: selectedSource?.start, + end: selectedSource?.end, + role: "resolution", + }, + }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts new file mode 100644 index 00000000..33cc63db --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts @@ -0,0 +1,60 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 pipeline.test.ts declaration. +export function queueWithPendingSave(): { state: ConversationPlan.State; proposal: Chat.Entry } { + let state = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + let postgres = message("postgres-queue-option", "Use a PostgreSQL queue."); + state = applyInference(state, { + id: postgres.id, + type: "option.added", + threadId: "queue-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: postgres.ts, + source: { + messageId: postgres.id, + author: postgres.author as ConversationPlan.SourceAuthor, + quote: postgres.text, + start: 0, + end: postgres.text.length, + role: "option", + }, + contribution: { + id: "postgres-option", + text: postgres.text, + authoring: "quoted", + targetId: "queue-thread", + }, + }, postgres); + let proposal = message("pending-sqs-save", "We should use SQS."); + return { + state: applyInference(state, { + id: "pending-sqs-save-event", + type: "settle.suggested", + threadId: "queue-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + optionId: "sqs-option", + }, proposal), + proposal, + }; +} diff --git a/apps/server/src/conversation-plan/pipeline-ordinary-save.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-ordinary-save.test-fixtures.ts new file mode 100644 index 00000000..5c48c6d3 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-ordinary-save.test-fixtures.ts @@ -0,0 +1,140 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { interpretMessage } from "./interpret"; +import { mockResult, seededOptionId } from "./interpret.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 pipeline.test.ts declarations. +export type MockAnswers = Record; + +export type MockMutation = (result: any, prefix?: string) => void; + +export function emailState(): ConversationPlan.State { + return stateWithOption( + "Which service should send notification emails?", + "email-thread", + "existing-mailgun", + "We should use Mailgun for notification emails.", + ); +} + +export function proposalTriage( + target: string, + act = "proposal", + overrides: MockAnswers = {}, +): MockAnswers { + return { + new_question: 0.1, + new_option: 0.98, + act, + thread_target: target, + significance: 2, + explicit_resolution: 0.1, + ...overrides, + }; +} + +export function newChoiceAnswers( + prefix: string, + target: string, + overrides: MockAnswers = {}, +): MockAnswers { + return withCandidateOverrides(prefix, { + [`${prefix}_role`]: "resolution", + [`${prefix}_thread`]: target, + [`${prefix}_option`]: "new", + [`${prefix}_chosen_option`]: "new", + [`${prefix}_new_option`]: 0.98, + [`${prefix}_planning_substance`]: 0.96, + [`${prefix}_support`]: 0.96, + [`${prefix}_explicit_resolution`]: 0.1, + [`${prefix}_duplicate`]: 0.04, + }, overrides); +} + +export function knownChoiceAnswers( + prefix: string, + target: string, + option: string, + role = "resolution", + overrides: MockAnswers = {}, +): MockAnswers { + return withCandidateOverrides(prefix, { + [`${prefix}_role`]: role, + [`${prefix}_thread`]: target, + [`${prefix}_option`]: option, + [`${prefix}_chosen_option`]: option, + [`${prefix}_new_option`]: 0.05, + [`${prefix}_planning_substance`]: 0.05, + [`${prefix}_support`]: 0.96, + [`${prefix}_explicit_resolution`]: 0.1, + [`${prefix}_duplicate`]: 0.04, + }, overrides); +} + +export function withCandidateOverrides( + prefix: string, + answers: MockAnswers, + overrides: MockAnswers, +): MockAnswers { + for (let [key, value] of Object.entries(overrides)) { + answers[key.startsWith(`${prefix}_`) ? key : `${prefix}_${key}`] = value; + } + return answers; +} + +export async function mockInterpret( + current: Chat.Entry, + state: ConversationPlan.State, + triage: MockAnswers, + candidate: (prefix: string) => MockAnswers, + mutate?: MockMutation, + recent: readonly Chat.Entry[] = [], +) { + return interpretMessage({ + channelId: "channel", + message: current, + recent, + state, + ask: async request => { + let isTriage = "new_question" in request.questions; + let prefix = Object.keys(request.questions).find(key => /^c\d+_role$/.test(key)) + ?.replace(/_role$/, ""); + let result = mockResult(request.questions, isTriage ? triage : candidate(prefix ?? "c0")); + mutate?.(result, isTriage ? undefined : prefix); + return result; + }, + }); +} + +export function expectSourcedOptionSave( + events: ConversationPlan.Event[], + threadId: string, + quote: { quote: string; start: number; end: number }, +): void { + expect(events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + let added = events[0]; + expect(added).toMatchObject({ + type: "option.added", + threadId, + contribution: { text: quote.quote }, + source: { ...quote, role: "option" }, + }); + expect(events[1]).toMatchObject({ + type: "settle.suggested", + threadId, + optionId: added?.type === "option.added" ? added.contribution.id : undefined, + source: { ...quote, role: "resolution" }, + }); +} + +export function optionChoice(choice: string): any { + return { + type: "choice", + choice, + confidence: 0.95, + probabilities: Object.fromEntries( + [seededOptionId, "new", "none"].map((key) => [key, key === choice ? 0.96 : 0.02]), + ), + }; +} diff --git a/apps/server/src/conversation-plan/pipeline-ordinary-save.test.ts b/apps/server/src/conversation-plan/pipeline-ordinary-save.test.ts new file mode 100644 index 00000000..1f768dde --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-ordinary-save.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption } from "./policy-terminal.test-fixtures"; +import { + emailState, + expectSourcedOptionSave, + mockInterpret, + newChoiceAnswers, + optionChoice, + proposalTriage, +} from "./pipeline-ordinary-save.test-fixtures"; + +// Whole archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 pipeline.test.ts callbacks. +test("a known choice becomes a settle suggestion, never a decision", () => { + let current = message("m2", "Let's just go with the optional outline."); + let state = withOption(seeded()); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + candidates: [ + { + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice(seededOptionId), + }, + }, + ], + }); + expect(output.events.map((event) => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ optionId: seededOptionId }); + expect("source" in output.events[0] ? output.events[0].source?.quote : undefined).toBe( + current.text, + ); + expect(state.threads[0].status).toBe("exploring"); + let ambiguous = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.98 }), + candidates: [ + { + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ + role: "resolution", + thread: "thread-a", + threadProbability: 0.65, + explicit_resolution: 0.98, + }), + chosen_option: optionChoice(seededOptionId), + }, + }, + ], + }); + expect(ambiguous.events).toEqual([]); +}); + +test.each( + [ + { + id: "new-postmark-choice", + text: "We should use Postmark for notification emails.", + act: "proposal", + }, + { + id: "new-postmark-commitment", + text: "Let's do Postmark for notification emails.", + act: "commitment", + }, + { + id: "new-postmark-decided-choice", + text: "We've decided to use Postmark for notification emails.", + act: "commitment", + }, + ] as const, +)("a new choice in $act text stages it and suggests Save", async ({ id, text, act }) => { + let current = message(id, text); + let state = emailState(); + let quote = extractQuotes(text)[0]!; + let output = await mockInterpret( + current, + state, + proposalTriage("email-thread", act, { explicit_resolution: 0.94 }), + prefix => newChoiceAnswers(prefix, "email-thread", { explicit_resolution: 0.94 }), + ); + expectSourcedOptionSave(output.events, "email-thread", quote); + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); +}); + +test("direct choices for distinct threads both suggest Save", async () => { + let current = message( + "two-thread-proposals", + "We should use Postmark for notifications. We should use Redis for background jobs.", + ); + let state = emailState(); + state = addThreadWithOption( + state, + "jobs-thread", + "Should background jobs use Redis or a PostgreSQL queue?", + "existing-postgres-queue", + "Use a PostgreSQL queue.", + ); + let quotes = extractQuotes(current.text); + let output = await mockInterpret( + current, + state, + proposalTriage("none"), + prefix => newChoiceAnswers(prefix, prefix === "c0" ? "email-thread" : "jobs-thread"), + ); + expect(output.events.map(event => event.type)).toEqual([ + "option.added", + "settle.suggested", + "option.added", + "settle.suggested", + ]); + for (let [index, quote] of quotes.entries()) { + expectSourcedOptionSave( + output.events.slice(index * 2, index * 2 + 2), + index === 0 ? "email-thread" : "jobs-thread", + quote, + ); + } + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/pipeline-provenance-1.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-1.test.ts new file mode 100644 index 00000000..ff6462b3 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-1.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("another member's plain assent agrees without recording a decision", () => { + let state = settledBy(seeded(), "bob"); + for ( + let [id, text, role] of [ + ["assent-support", "Sounds good to me.", "support"], + ["assent-none", "Sounds good to me.", "none"], + ["seems-good", "Seems good to me.", "none"], + ["no-reason-not", "Sounds good—no reason not to.", "support"], + ] + ) { + let current = message(id, text, "alice"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.9 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role, thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.9 }, + }, + }], + }); + expect(output.events.map((event) => event.type)).toEqual( + role === "support" ? ["stance.changed", "settle.agreed"] : ["settle.agreed"], + ); + expect(output.events.at(-1)).toMatchObject({ + type: "settle.agreed", + optionId: seededOptionId, + origin: "classifier", + }); + expect(state.threads[0].decision).toBeUndefined(); + } +}); + +test("a later attribution vetoes only the earlier assent, retaining objection and question", () => { + let state = settledBy(seeded(), "Bob"); + let current = message( + "mixed-withdrawal", + "Sounds good to me. That's what Bob said, but I disagree. What about agent access?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ + new_question: 0.95, + c0_owned_unretracted: 0.2, + c1_owned_unretracted: 0.9, + c2_owned_unretracted: 0.9, + }), + candidates: [ + { + ...quotes[0]!, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.95 }, + }, + }, + { ...quotes[1]!, answers: follow({ role: "objection", thread: "thread-a" }) }, + { ...quotes[2]!, answers: follow({ role: "question", thread: "new" }) }, + ], + }); + expect(output.events.map(event => event.type)).toEqual(["stance.changed", "thread.opened"]); + expect(output.events[0]).toMatchObject({ position: "oppose" }); + expect(output.events.map(event => "source" in event ? event.source?.quote : "")) + .toEqual([quotes[1]!.quote, quotes[2]!.quote]); + expect(output.outcomes[0]?.gate).toBe("source ownership unclear"); + expect(output.events.some(event => + event.type === "settle.agreed" + || event.type === "settle.suggested" || event.type === "decision.recorded" + )).toBe(false); +}); + +test("direct support for the pending option agrees even when generic agreement is low", () => { + let state = settledBy(seeded(), "bob"); + for ( + let [id, text] of [ + ["direct-support", "I support the optional outline."], + ["qualified-support", "I don't see a reason not to use the optional outline."], + ] + ) { + let current = message(id, text, "alice"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + option: optionChoice(seededOptionId), + agrees_with_settle: { type: "noul", noul: 0.1 }, + }, + }], + }); + expect(output.events.map((event) => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + ]); + expect(output.events[1]).toMatchObject({ optionId: seededOptionId }); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-provenance-2.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-2.test.ts new file mode 100644 index 00000000..31487438 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-2.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("reported, sarcastic, and retracted quotes cannot borrow strong agreement evidence", () => { + for ( + let [text, owned] of [ + ["Bob said, 'sounds good to me.'", 0.04], + ["Sounds good to me 🙄. What about agents?", 0.22], + ["Sounds good to me. Actually, no—I disagree with GitHub.", 0.17], + ] as const + ) { + let current = message(`negative-${owned}`, text, "Jules"); + let quote = extractQuotes(current.text)[0]!; + let output = planEvents({ + channelId: "channel", + message: current, + state: settledBy(seeded(), "Mina"), + first: first({ support: 0.95, c0_owned_unretracted: owned }), + candidates: [{ + ...quote, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.95 }, + }, + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0]?.gate).toBe("source ownership unclear"); + } + let current = message("retracted-resolution", "Let's use Redis. Actually, no.", "Jules"); + let quote = extractQuotes(current.text)[0]!; + let output = planEvents({ + channelId: "channel", + message: current, + state: withOption(seeded()), + first: first({ explicit_resolution: 0.98, c0_owned_unretracted: 0.09 }), + candidates: [{ + ...quote, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0]?.gate).toBe("source ownership unclear"); +}); + +test("a separately sourced concern and question can follow assent in one message", () => { + let state = settledBy(seeded(), "Mina"); + let other = structuredClone(state.threads[0]!); + other.id = "thread-b"; + other.question = "How should we handle agent access?"; + other.pendingSettle = undefined; + state.threads.push(other); + let current = message( + "mixed-three", + "Sounds good to me. I'm worried about agent access. Should we use Copilot?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ + new_question: 0.95, + c0_owned_unretracted: 0.9, + c1_owned_unretracted: 0.9, + c2_owned_unretracted: 0.9, + }), + candidates: [ + { + ...quotes[0]!, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.95 }, + }, + }, + { ...quotes[1]!, answers: follow({ role: "objection", thread: "thread-b" }) }, + { ...quotes[2]!, answers: follow({ role: "question", thread: "new" }) }, + ], + }); + expect(output.events.map(event => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + "stance.changed", + "thread.opened", + ]); + expect(output.events.map(event => "source" in event ? event.source?.quote : "")) + .toEqual([quotes[0]!.quote, quotes[0]!.quote, quotes[1]!.quote, quotes[2]!.quote]); + expect( + output.events.every(event => "source" in event && event.source?.messageId === current.id), + ) + .toBe(true); + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts new file mode 100644 index 00000000..9e61b970 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import { optionIdFor, planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, member, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { queueWithPendingSave } from "./pipeline-ordinary-save-pending.test-fixtures"; + +test("specific pending agreement corroborates support without changing the role gate", () => { + let current = message("pending-assent", "Sounds good to me.", "Jules"); + let answers = { + ...follow({ role: "support", thread: "thread-a" }), + support: { type: "noul" as const, noul: 0.62 }, + agrees_with_settle: { type: "noul" as const, noul: 0.71 }, + }; + let run = (state: ConversationPlan.State) => + planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.39, c0_owned_unretracted: 0.92 }), + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers }], + }); + expect(run(settledBy(seeded(), "Mina")).events.map(event => event.type)) + .toEqual(["stance.changed", "settle.agreed"]); + expect(run(withOption(seeded())).events).toEqual([]); + let pending = settledBy(seeded(), "Mina"); + let otherId = optionIdFor("channel", "other-pending-test", 0, 1000); + pending.threads[0]!.contributions.push({ + ...pending.threads[0]!.contributions[0]!, + id: otherId, + text: "Use another option.", + }); + let conflicting = planEvents({ + channelId: "channel", + message: current, + state: pending, + first: first({ c0_owned_unretracted: 0.92 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...answers, + option: { + type: "choice", + choice: otherId, + confidence: 0.95, + probabilities: { [otherId]: 0.95, [seededOptionId]: 0.05 }, + }, + }, + }], + }); + expect(conflicting.events).toEqual([]); + let self = planEvents({ + channelId: "channel", + message: { ...current, author: member("Mina") }, + state: pending, + first: first({ c0_owned_unretracted: 0.92 }), + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers }], + }); + expect(self.events).toEqual([]); +}); + +test("withdrawal needs the own accepted pending option and cannot become generic opposition", () => { + let { state } = queueWithPendingSave(); + let assess = ( + candidateState = state, + handle = "Mina", + text = "I take back my SQS preference for now.", + overrides: Record = {}, + owned = 0.95, + ) => { + let current = message(`withdraw-${handle}`, text, handle); + let quote = extractQuotes(text)[0]!; + return planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: first({ c0_owned_unretracted: owned, correction: 0.91, withdrawal: 0.92 }), + candidates: [{ + ...quote, + answers: { + ...follow({ role: "objection", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + withdraws_pending_settle: { type: "noul", noul: 0.95 }, + ...overrides, + }, + }], + }); + }; + expect(assess().events.map(event => event.type)).toEqual(["stance.changed"]); + expect(assess(state, "Jules").events).toEqual([]); + expect(assess(state, "Mina", undefined, { option: confidentChoice("postgres-option") }).events) + .toEqual([]); + expect(assess(state, "Mina", undefined, { thread: confidentChoice("none") }).events) + .toEqual([]); + expect( + assess(state, "Mina", undefined, { + withdraws_pending_settle: { type: "noul", noul: 0.2 }, + }).events, + ).toEqual([]); + let absent = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + expect(assess(absent).events).toEqual([]); + expect(assess(state, "Mina", "If I take back my SQS preference, revisit this.").events) + .toEqual([]); + expect(assess(state, "Mina", "I take back my SQS preference if launch slips.").events) + .toEqual([]); + expect(assess(state, "Mina", "Alice said I take back my SQS preference.", {}, 0.1).events) + .toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts new file mode 100644 index 00000000..bc3bce1e --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { effectivePending } from "./preference"; +import { planEvents } from "./policy"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { queueWithPendingSave } from "./pipeline-ordinary-save-pending.test-fixtures"; + +test("a direct proposal withdrawal becomes one sourced neutral stance, leaving another clause eligible", () => { + let { state, proposal } = queueWithPendingSave(); + let current = message( + "withdraw-sqs", + "I take back my SQS preference for now. Let's verify backups before choosing.", + "Mina", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toHaveLength(2); + let triage = buildTriageRequest(current, [proposal], state.threads, quotes, state.events); + expect(triage.questions.withdrawal?.type).toBe("noul"); + let targeting = buildCandidateTargetingRequest( + current, + [proposal], + state.threads, + quotes, + 0, + state.events, + ); + expect(targeting.questions.c0_withdraws_pending_settle?.type).toBe("noul"); + expect(targeting.questions.c0_material_objection).toBeUndefined(); + expect(targeting.questions.c0_duplicate).toBeUndefined(); + expect(Object.keys(targeting.questions)).toHaveLength(14); + let firstAnswers = first({ withdrawal: 0.92, correction: 0.91 }); + let withdrawal = { + ...quotes[0]!, + answers: { + ...follow({ role: "objection", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + withdraws_pending_settle: { type: "noul" as const, noul: 0.94 }, + }, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: firstAnswers, + candidates: [withdrawal, { + ...quotes[1]!, + answers: { + ...follow({ role: "constraint", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + relation: confidentChoice("qualifies"), + duplicate: { type: "noul" as const, noul: 0.1 }, + }, + }], + }); + expect(result.events.map(event => event.type)).toEqual([ + "stance.changed", + "constraint.added", + "settle.deferred", + ]); + expect(result.events[0]).toMatchObject({ + threadId: "queue-thread", + optionId: "sqs-option", + position: "neutral", + source: { ...quotes[0], role: "withdrawal" }, + }); + expect(result.events[1]).toMatchObject({ source: { ...quotes[1], role: "constraint" } }); + let proposalEvent = state.events.findLast(event => + event.type === "settle.suggested" && event.threadId === "queue-thread" + ); + expect(result.events[2]).toMatchObject({ + type: "settle.deferred", + threadId: "queue-thread", + proposalId: proposalEvent?.id, + source: { ...quotes[1], role: "constraint" }, + }); + let neutral = result.events[0] as Extract; + let after = result.events.reduce( + (next, event) => applyInference(next, event, current), + state, + ); + expect(effectivePending(after.threads[0]!, after.events)).toBeUndefined(); + expect(after.threads[0]!.pendingSettle).toEqual(state.threads[0]!.pendingSettle); + expect(after.threads[0]!.decisionHistory).toEqual(state.threads[0]!.decisionHistory); + expect(() => + applyInference(state, { + ...neutral, + source: { ...neutral.source, role: "objection" }, + }, current) + ).toThrow("stance source role disagrees"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-1.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-1.test.ts new file mode 100644 index 00000000..41040fd5 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-1.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { buildResearchOfferRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { researchQuoteSupportsPair } from "./service"; +import { seeded, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("research selection shows effective relabeled options and exact quote choices", () => { + let state = withOption(seeded()); + let option = state.threads[0]!.contributions[0]!; + option.text = "R2"; + option.displayLabel = "Backblaze B2"; + state.threads[0]!.contributions.push({ + ...option, + id: "other-option", + text: "Amazon S3", + displayLabel: undefined, + }); + let current = message("cost-request", "Compare current provider prices."); + let request = buildResearchOfferRequest( + current, + [], + state.threads, + extractQuotes(current.text), + ); + let criteria = request.questions.research_option_a; + expect(criteria?.type).toBe("choice"); + if (criteria?.type !== "choice") throw new Error("missing option choices"); + expect(criteria.criteria[option.id]).toContain("Backblaze B2"); + expect(criteria.criteria[option.id]).not.toContain("R2 (in"); + expect( + (request.state as any).threads[0].options.find((item: any) => item.id === option.id).text, + ) + .toBe("Backblaze B2"); + expect((request.state as any).candidates[0]).toEqual({ + quote: current.text, + start: 0, + end: current.text.length, + }); + expect(request.questions.research_q0_owned?.type).toBe("noul"); +}); + +test("research quote rejects explicit alternatives outside the current pair", () => { + let options = [{ text: "Amazon S3" }, { text: "Cloudflare R2" }]; + expect(researchQuoteSupportsPair("Compare Amazon S3 and Redis costs.", options)).toBe(false); + expect(researchQuoteSupportsPair("Amazon S3 or Redis costs?", options)).toBe(false); + expect(researchQuoteSupportsPair("How do Amazon S3 and Redis costs compare?", options)) + .toBe(false); + expect(researchQuoteSupportsPair("Amazon S3 is cheaper than Redis at current prices.", options)) + .toBe(false); + expect( + researchQuoteSupportsPair("Compare Amazon S3 and Cloudflare R2 and Redis costs.", options), + ) + .toBe(false); + expect(researchQuoteSupportsPair("Compare Amazon S3 and Cloudflare R2 costs.", options)) + .toBe(true); + expect(researchQuoteSupportsPair("R2's egress could matter for thumbnails.", options)) + .toBe(true); + expect(researchQuoteSupportsPair("I don't know current provider prices.", options)) + .toBe(true); + expect(researchQuoteSupportsPair("Compare Amazon S3 and Cloudflare R2 costs.", [ + options[0]!, + { text: "Cloudflare R2", displayLabel: "Backblaze B2" }, + ])).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts new file mode 100644 index 00000000..052c0dab --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { buildTargetingRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("agreement targeting is requested only when a proposal is pending", () => { + let current = message("assent", "Sounds good to me.", "alice"); + let candidates = [{ quote: current.text, start: 0, end: current.text.length }]; + let without = buildTargetingRequest(current, [], withOption(seeded()).threads, candidates); + let withProposal = buildTargetingRequest( + current, + [], + settledBy(seeded(), "bob").threads, + candidates, + ); + expect(without.questions.c0_agrees_with_settle).toBeUndefined(); + expect(withProposal.questions.c0_agrees_with_settle).toMatchObject({ + type: "noul", + criteria: { + true: "The speaker agrees with the pending proposal to settle.", + false: "No agreement with a pending proposal.", + }, + }); + expect( + (withProposal.state as { threads: Array<{ pendingSettle?: unknown }> }).threads[0] + .pendingSettle, + ).toEqual({ + optionId: seededOptionId, + option: "Use an optional outline.", + proposer: "bob", + }); +}); + +test("multiple pending proposals keep assent targeting explicitly ambiguous", () => { + let state = settledBy(seeded(), "bob"); + let other = structuredClone(state.threads[0]!); + other.id = "thread-b"; + other.question = "Should we add an optional sidebar?"; + other.contributions[0]!.id = "sidebar-option"; + other.contributions[0]!.text = "Add an optional sidebar."; + other.pendingSettle = { + optionId: "sidebar-option", + proposer: "cara", + messageId: "sidebar-proposal", + }; + state.threads.push(other); + let current = message( + "ambiguous-assent", + "Sounds good to me. What about agent access?", + "alice", + ); + let request = buildTargetingRequest(current, [], state.threads, extractQuotes(current.text)); + let target = request.questions.c0_thread as Extract; + expect(target.criteria["thread-a"]).toContain("Use an optional outline."); + expect(target.criteria["thread-b"]).toContain("Add an optional sidebar."); + let agreement = request.questions.c0_agrees_with_settle; + expect(agreement?.instructions).toContain("If several proposals are pending"); + expect(agreement?.instructions).not.toContain("the pending proposal by bob"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-3.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-3.test.ts new file mode 100644 index 00000000..8b9b57a4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-3.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption, stateWithOption } from "./policy-terminal.test-fixtures"; +import { + emailState, + knownChoiceAnswers, + mockInterpret, + newChoiceAnswers, + proposalTriage, +} from "./pipeline-ordinary-save.test-fixtures"; + +test("weak, hedged, negative, reported, duplicate, and list wording cannot preselect", async () => { + for ( + let item of [ + { id: "postmark-guess", text: "We should use Postmark for emails, I guess.", empty: false }, + { + id: "postmark-probably", + text: "We should use Postmark for emails, probably.", + empty: false, + }, + { + id: "hedged-new-choice", + text: "Maybe we should use Postmark for notification emails.", + empty: true, + }, + { + id: "negated-new-choice", + text: "We should not use Postmark for notification emails.", + empty: true, + }, + { + id: "reported-new-choice", + text: "Alice said we should use Postmark for notification emails.", + empty: true, + }, + { + id: "duplicate-new-choice", + text: "We should use Mailgun for notification emails.", + empty: true, + }, + { + id: "plain-option-list", + text: "Postmark or Amazon SES for notification emails.", + empty: false, + }, + ] as const + ) { + let current = message(item.id, item.text); + let duplicate = item.id === "duplicate-new-choice" ? 0.96 : 0.04; + let output = await mockInterpret( + current, + emailState(), + proposalTriage("email-thread", "proposal", { duplicate }), + prefix => + newChoiceAnswers(prefix, "email-thread", { + role: item.id === "plain-option-list" ? "option" : "resolution", + duplicate, + }), + ); + expect(output.events.some(event => event.type === "settle.suggested")).toBe(false); + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); + if (item.empty) expect(output.events).toEqual([]); + } +}); + +test("ambiguous thread targeting cannot settle a direct new choice", async () => { + let state = emailState(); + state = addThreadWithOption( + state, + "other-email-thread", + "Should we use a different email provider?", + "existing-ses", + "Use Amazon SES.", + ); + let current = message( + "ambiguous-new-choice", + "We should use Postmark for notification emails.", + ); + let output = await mockInterpret( + current, + state, + proposalTriage("none"), + prefix => newChoiceAnswers(prefix, "none"), + (result, prefix) => { + if (!prefix) return; + let target = result.answers[`${prefix}_thread`]; + target.choice = "none"; + target.confidence = 0.5; + target.probabilities = { "email-thread": 0.47, "other-email-thread": 0.46, none: 0.07 }; + }, + ); + expect(output.events).toEqual([]); +}); + +test("I think we should use a known option suggests Save, not a decision", async () => { + let current = message("existing-option-recommendation", "I think we should use SQS."); + let state = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + let output = await mockInterpret( + current, + state, + proposalTriage("queue-thread", "proposal", { new_option: 0.1 }), + prefix => knownChoiceAnswers(prefix, "queue-thread", "sqs-option", "support"), + ); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ + threadId: "queue-thread", + optionId: "sqs-option", + source: { quote: current.text, start: 0, end: current.text.length, role: "resolution" }, + }); + expect(output.events.some(event => event.type === "option.added")).toBe(false); + expect(output.events.some(event => event.type === "decision.recorded")).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-4.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-4.test.ts new file mode 100644 index 00000000..c7296787 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-4.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("a clear owned team commitment suggests a known choice despite low settle scores", () => { + let state = withOption(seeded()); + state.threads[0]!.question = "Where should we host the app?"; + state.threads[0]!.contributions[0]!.text = "Host it on our own VPS"; + let current = message("vps-choice", "We've decided to host it on our own VPS"); + let input = { + channelId: "channel", + message: current, + state, + first: { + ...first({ explicit_resolution: 0.60, c0_owned_unretracted: 0.89 }), + act: { + type: "choice" as const, + choice: "commitment", + confidence: 0.89, + probabilities: { commitment: 0.89, proposal: 0.08, other: 0.03 }, + }, + }, + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.67 }), + role: { + type: "choice" as const, + choice: "resolution", + confidence: 0.89, + probabilities: { resolution: 0.89, support: 0.08, none: 0.03 }, + }, + chosen_option: { + type: "choice" as const, + choice: seededOptionId, + confidence: 0.92, + probabilities: { [seededOptionId]: 0.92, new: 0.04, none: 0.04 }, + }, + }, + }], + }; + let output = planEvents(input); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ + optionId: seededOptionId, + source: { quote: current.text, messageId: current.id }, + }); + expect(state.threads[0].status).toBe("exploring"); + let uncertainAct = planEvents({ + ...input, + first: { + ...input.first, + act: { + type: "choice", + choice: "proposal", + confidence: 0.89, + probabilities: { proposal: 0.89, commitment: 0.08, other: 0.03 }, + }, + }, + }); + expect(uncertainAct.events).toEqual([]); + expect(uncertainAct.outcomes[0]?.gate).toBe("settle authority unclear"); +}); + +test("a recommendation for a known VPS option does not add a duplicate", () => { + let state = withOption(seeded()); + state.threads[0]!.question = "Where should we host the app?"; + state.threads[0]!.contributions[0]!.text = "Host it on our own VPS"; + let current = message("vps-proposal", "We should also host it on our own VPS"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ explicit_resolution: 0.45, new_option: 0.82 }), + act: { + type: "choice", + choice: "proposal", + confidence: 1, + probabilities: { proposal: 1, commitment: 0 }, + }, + }, + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "option", thread: "thread-a", explicit_resolution: 0.67 }), + option: optionChoice(seededOptionId), + chosen_option: optionChoice(seededOptionId), + new_option: { type: "noul", noul: 0.91 }, + duplicate: { type: "noul", noul: 0.53 }, + support: { type: "noul", noul: 0.90 }, + }, + }], + }); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ optionId: seededOptionId }); + expect(state.threads[0]!.contributions).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-5.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-5.test.ts new file mode 100644 index 00000000..3afa1d98 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-5.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId, withOption } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { optionChoice } from "./pipeline-ordinary-save.test-fixtures"; + +test("an existing-option recommendation suggests the option when Jev's role is uncertain", () => { + let state = withOption(seeded()); + for ( + let [id, text, roleProbability, supportProbability, triageResolution, targetingResolution] of [ + ["s3", "We should do amazon S3 for uploaded files", 0.68, 0.26, 0.68, 0.89], + ["search", "And we should use elastic search", 0.44, 0.39, 0.74, 0.87], + ] as const + ) { + let current = message(id, text); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ explicit_resolution: triageResolution }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.9, + probabilities: { proposal: 0.9, evaluation: 0.1 }, + }, + }, + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ + role: "resolution", + thread: "thread-a", + explicit_resolution: targetingResolution, + }), + role: { + type: "choice", + choice: "resolution", + confidence: roleProbability, + probabilities: { + resolution: roleProbability, + support: supportProbability, + none: 1 - roleProbability - supportProbability, + }, + }, + support: { type: "noul", noul: 0.89 }, + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ + optionId: seededOptionId, + source: { quote: text, messageId: id }, + }); + expect(state.threads[0].status).toBe("exploring"); + } +}); + +test("an apostrophe-free let's choice suggests Jev's exact existing option", () => { + let state = withOption(seeded()); + let current = message("postmark", "Lets do postmark for notifications"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ explicit_resolution: 0.92 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.94 }), + chosen_option: optionChoice(seededOptionId), + }, + }], + }); + expect(output.events.map(event => event.type)).toEqual(["settle.suggested"]); + expect(output.events[0]).toMatchObject({ + optionId: seededOptionId, + source: { quote: current.text, messageId: current.id }, + }); + expect(state.threads[0].status).toBe("exploring"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts new file mode 100644 index 00000000..59ecb855 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test("two distinct supporters produce tentative leaning, not a decision", () => { + let state = seeded(); + let proposal = message("option", "Use an optional outline."); + state = applyInference(state, { + id: "option-event", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "option", + }, + contribution: { + id: seededOptionId, + text: proposal.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, proposal); + let firstSupport = message("first-support", "I support the optional outline.", "Theo"); + state = applyInference(state, { + id: "first-stance", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: firstSupport.id, + author: firstSupport.author as ConversationPlan.SourceAuthor, + quote: firstSupport.text, + start: 0, + end: firstSupport.text.length, + role: "support", + }, + optionId: seededOptionId, + position: "support", + }, firstSupport); + let current = message("second-support", "I also support the optional outline.", "Imani"); + let answers = { + ...follow({ role: "support", thread: "thread-a" }), + option: { + type: "choice", + choice: seededOptionId, + confidence: 0.95, + probabilities: { [seededOptionId]: 0.95, none: 0.05 }, + }, + }; + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: answers as any, + }], + }); + expect(output.events.map((event) => event.type)).toEqual(["stance.changed", "thread.leaning"]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-7.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-7.test.ts new file mode 100644 index 00000000..c3594043 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-7.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { interpretMessage } from "./interpret"; +import { mockResult, seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("multi-claim current text gets distinct source ranges and target choices", async () => { + let current = message( + "m5", + "Keep the outline optional; separately, should each repository remember my choice?", + ); + let seen: Record[] = []; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: seeded(), + ask: async (request) => { + seen.push(request.state as Record); + if (seen.length === 1) { + return mockResult(request.questions, { + new_question: 0.9, + support: 0.9, + significance: 2, + }); + } + return mockResult(request.questions, { + c0_role: "support", + c0_support: 0.95, + c0_thread: "thread-a", + c1_role: "question", + c1_thread: "new", + }); + }, + }); + expect(seen).toHaveLength(3); + expect(output.events.map((event) => event.type)).toContain("thread.opened"); + expect(output.events.map((event) => event.type)).toContain("stance.changed"); + expect("source" in output.events[0] ? output.events[0].source?.quote : undefined).not.toBe( + "source" in output.events[1] ? output.events[1].source?.quote : undefined, + ); +}); + +test("mixed message keeps per-candidate ranges and review gate in analysis", async () => { + let current = message( + "mixed", + "We've decided to use an outline. Should we remember the choice?", + ); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: seeded(), + ask: async (request) => { + calls++; + return mockResult( + request.questions, + calls === 1 + ? { explicit_resolution: 0.98, new_question: 0.95 } + : { + c0_role: "resolution", + c0_thread: "none", + c0_explicit_resolution: 0.98, + c1_role: "question", + c1_thread: "new", + }, + ); + }, + }); + expect(output.events.map((event) => event.type)).toEqual(["thread.opened"]); + expect(output.analysis.outcomes).toHaveLength(2); + expect(output.analysis.outcomes?.[0]).toMatchObject({ status: "review", start: 0 }); + expect(output.analysis.outcomes?.[0].gate).toContain("target"); + expect(output.analysis.outcomes?.[1]).toMatchObject({ + status: "accepted", + eventIds: [output.events[0].id], + }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-alternatives.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-alternatives.test.ts new file mode 100644 index 00000000..e4efb7d5 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-alternatives.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { seeded } from "./interpret.test-fixtures"; +import { interpretLowOwnership } from "./pipeline-recovery.test-fixtures"; + +test.each([ + { + id: "notification-email-providers", + text: "Which service should send notification emails: Postmark or Amazon SES?", + options: ["Postmark", "Amazon SES"], + }, + { + id: "authentication-approaches", + text: "Should we use Auth0, roll our own, or use GitHub auth?", + options: ["use Auth0", "roll our own", "use GitHub auth"], + }, + { + id: "audit-log-storage", + text: "Should audit logs go in PostgreSQL or object storage?", + options: ["PostgreSQL", "object storage"], + }, + { + id: "background-job-queues", + text: "Should background jobs use Redis or a PostgreSQL queue?", + options: ["Redis", "a PostgreSQL queue"], + }, +])("direct alternatives in $id retain exact spans despite low classifier ownership", async ({ + id, + text, + options, +}) => { + let output = await interpretLowOwnership(id, text); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened" as const, + ...options.map(() => "option.added" as const), + ]); + expect(output.events[0]).toMatchObject({ + question: text, + source: { quote: text, start: 0, end: text.length, role: "question" }, + }); + let optionEvents = output.events.slice(1); + expect(optionEvents.map(event => event.type === "option.added" ? event.contribution.text : "")) + .toEqual([...options]); + expect(optionEvents.map(event => + event.type === "option.added" + ? { + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + } + : undefined + )) + .toEqual(options.map(option => { + let start = text.indexOf(option); + return { quote: option, start, end: start + option.length }; + })); +}); + +test("a direct alternatives question opens beside an unrelated existing thread", async () => { + let text = "Should audit logs go in PostgreSQL or object storage?"; + let options = ["PostgreSQL", "object storage"]; + let output = await interpretLowOwnership("audit-with-unrelated-thread", text, { + weakFragments: false, + state: seeded(), + candidateThreadTarget: "none", + }); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + ]); + expect(output.events[0]).toMatchObject({ + question: text, + source: { quote: text, start: 0, end: text.length, role: "question" }, + }); + let optionEvents = output.events.slice(1); + expect(optionEvents.map(event => event.type === "option.added" ? event.contribution.text : "")) + .toEqual(options); + expect(optionEvents.map(event => + event.type === "option.added" + ? { + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + } + : undefined + )) + .toEqual(options.map(option => { + let start = text.indexOf(option); + return { quote: option, start, end: start + option.length }; + })); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-ambiguity.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-ambiguity.test.ts new file mode 100644 index 00000000..78856be5 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-ambiguity.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { interpretLowOwnership, stateWithQuestion } from "./pipeline-recovery.test-fixtures"; + +test("ambiguous exact-question matches do not recover a partial card", async () => { + let text = "Should audit logs go in PostgreSQL or object storage?"; + let state = stateWithQuestion("thread:first-audit", text, ["PostgreSQL"]); + state = stateWithQuestion("thread:second-audit", text, [], state); + let output = await interpretLowOwnership("repeat-audit-ambiguous-state", text, { + weakFragments: false, + state, + triageTarget: "new", + candidateThreadTargets: { c0_thread: "new", c1_thread: "new" }, + candidateRoles: { c0_role: "question", c1_role: "option" }, + candidateDuplicates: { c0_duplicate: 0.95 }, + }); + expect(output.events).toEqual([]); +}); + +test.each([ + { + id: "reported-email-question", + text: "Mina asked, “Should we use Postmark or Amazon SES for notification emails?”", + }, + { + id: "reported-email-provider-question", + text: "Which service did Alice say we should use: Postmark or SES?", + }, + { + id: "negated-email-choice", + text: "Should we not use Postmark or Amazon SES for notification emails?", + }, + { + id: "negated-email-provider-question", + text: "Which service shouldn't we use: Postmark or SES?", + }, + { + id: "curly-negated-email-provider-question", + text: "Which service shouldn’t we use: Postmark or SES?", + }, + { + id: "indirect-email-question", + text: "Would it make sense to use Postmark or Amazon SES for notification emails?", + }, + { + id: "mixed-context-email-question", + text: "Quick question. Should we use Postmark or Amazon SES?", + }, +])("low ownership keeps $id from opening a sourced choice", async ({ id, text }) => { + let output = await interpretLowOwnership(id, text, { weakFragments: false }); + expect(output.events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-effects.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-effects.test.ts new file mode 100644 index 00000000..9461e34e --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-effects.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import { applyInference, initialState } from "./domain"; +import { effectsFor } from "./effects"; +import { interpretMessage } from "./interpret"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("a distinct new option is still added without claiming an existing card option", () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let quote = "I'd pick Slate for the spike;"; + let current = message("d01-m6-new", quote, "Mei"); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ new_option: 0.95, c0_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: [{ + quote, + start: 0, + end: quote.length, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + }], + }); + let added = result.events.filter(event => event.type === "option.added"); + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ + type: "option.added", + threadId, + source: { messageId: current.id, quote, role: "option" }, + contribution: { text: quote, targetId: threadId, authoring: "quoted" }, + }); + let addedOption = added[0]; + if (addedOption?.type !== "option.added") throw new Error("a distinct option was not added"); + expect(options.some(option => option.id === addedOption.contribution.id)).toBe(false); + expect(state.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); +}); + +test("an explicit multi-option question opens one thread with its exact quoted options", async () => { + let recent = [message("figma-purpose", "Okay we gotta figure out what we're doing for auth")]; + let current = message( + "figma-options", + "Should we use an off the shelf solution like Auth0? Or roll our own? Or use something like GitHub auth?", + "Jules", + ); + let state = initialState(); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent, + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { + new_question: 0.97, + act: "question", + thread_target: "new", + new_option: 0.96, + significance: 2, + } + : { + c0_role: "option", + c0_thread: "new", + c0_new_option: 0.96, + c0_duplicate: 0.84, + c1_role: "option", + c1_thread: "none", + c1_new_option: 0.93, + c2_role: "option", + c2_thread: "new", + c2_new_option: 0.96, + }, + ), + }); + expect(calls).toBe(4); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "option.added", + ]); + let opened = output.events[0]!; + expect(opened).toMatchObject({ + question: current.text, + source: { quote: current.text, start: 0, end: current.text.length }, + }); + let quotes = extractQuotes(current.text); + expect(output.events.slice(1).map(event => "source" in event ? event.source?.quote : "")) + .toEqual(quotes.map(quote => quote.quote)); + let accepted = output.events.reduce( + (next, event) => applyInference(next, event, current), + state, + ); + expect(effectsFor(output.events, accepted)).toMatchObject([{ + kind: "insert-card", + options: quotes.map(quote => ({ label: quote.quote })), + }]); + let targeting = output.analysis.passes[1]!.answers; + let replay = planEvents({ + channelId: "channel", + message: current, + state, + first: output.analysis.passes[0]!.answers, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: Object.fromEntries( + Object.entries(targeting) + .filter(([key]) => key.startsWith(`c${index}_`)) + .map(([key, answer]) => [key.slice(3), answer]), + ), + })), + }); + expect(replay.events.map(event => event.id)).toEqual(output.events.map(event => event.id)); + let duplicate = replay.events.reduce( + (next, event) => applyInference(next, event, current), + accepted, + ); + expect(duplicate.events).toEqual(accepted.events); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts new file mode 100644 index 00000000..68ec2b3f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test.each([ + { + name: "an unrelated opening question with no option evidence", + text: "Who owns authentication? Use Auth0. Use GitHub auth.", + role: "question", + newOption: 0.05, + expected: ["thread.opened"], + }, + { + name: "a generic concern even with a noisy option score", + text: "I'm worried about login usability. Use Auth0. Use GitHub auth.", + role: "objection", + newOption: 0.95, + expected: [], + }, +])("multi-option grouping rejects $name", ({ text, role, newOption, expected }) => { + let current = message("unrelated-group", text, "Jules"); + let quotes = extractQuotes(current.text); + let triage = mockResult(buildTriageRequest(current, [], [], quotes).questions, { + new_question: 0.97, + new_option: 0.95, + act: "question", + thread_target: "new", + significance: 2, + }).answers; + let opening = follow({ role, thread: "new" }); + opening.new_option = { type: "noul", noul: newOption }; + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: index === 0 ? opening : follow({ role: "option", thread: "new" }), + })), + }); + expect(output.events.map(event => event.type)).toEqual([...expected]); + expect(output.events.some(event => event.type === "option.added")).toBe(false); + if (role === "question") { + expect(output.events[0]).toMatchObject({ question: quotes[0]!.quote }); + } +}); + +test("an unowned opening quote cannot bootstrap a multi-option thread", () => { + let current = message( + "reported-options", + "Should we use Auth0? Or roll our own? Or use GitHub auth?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.95, c0_owned_unretracted: 0.2 }), + act: { + type: "choice", + choice: "question", + confidence: 0.95, + probabilities: { question: 0.95, none: 0.05 }, + }, + thread_target: { + type: "choice", + choice: "new", + confidence: 0.95, + probabilities: { new: 0.95, none: 0.05 }, + }, + }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: follow({ role: "option", thread: index ? "none" : "new" }), + })), + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0]?.gate).toBe("source ownership unclear"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-refusal.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-refusal.test.ts new file mode 100644 index 00000000..9919c7d7 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-refusal.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test.each( + [ + [ + "option-only prose", + "Auth0 is an option. Custom login is another.", + { new_question: 0.05, act: "proposal", thread_target: "none", new_option: 0.95 }, + { c0_role: "option", c0_thread: "none", c1_role: "option", c1_thread: "none" }, + ], + [ + "mixed candidate roles", + "Auth0 or custom login? React or Vue?", + { new_question: 0.97, act: "question", thread_target: "new" }, + { c0_role: "option", c0_thread: "new", c1_role: "reason", c1_thread: "none" }, + ], + [ + "uncertain new target", + "Should we use Auth0? Or custom login?", + { new_question: 0.97, act: "question", thread_target: "none" }, + { c0_role: "option", c0_thread: "new", c1_role: "option", c1_thread: "none" }, + ], + [ + "repeated option labels", + "Use Auth0. Use Auth0.", + { new_question: 0.97, act: "question", thread_target: "new" }, + { c0_role: "option", c0_thread: "new", c1_role: "option", c1_thread: "none" }, + ], + ] as const, +)("multi-option grouping refuses %s", async (_, text, triage, targeting) => { + let current = message("not-a-group", text); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: initialState(), + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { ...triage, significance: 2 } + : { ...targeting, c0_new_option: 0.95, c1_new_option: 0.95 }, + ), + }); + expect(output.events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-repeat.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-repeat.test.ts new file mode 100644 index 00000000..2a263640 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-repeat.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { message } from "./policy-initial.test-fixtures"; +import { + interpretLowOwnership, + repeatQuestionCases, + stateWithQuestion, +} from "./pipeline-recovery.test-fixtures"; + +test.each([ + { id: "same-thread", targetKind: "same" }, + { id: "ambiguous-target", targetKind: "none" }, +])( + "a repeated audit question does not open another thread for $id", + async ({ id, targetKind }) => { + let text = "Should audit logs go in PostgreSQL or object storage?"; + let existing = message("existing-audit-question", text); + let threadId = "thread:existing-audit"; + let state = applyInference(initialState(), { + id: "existing-audit-thread", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: existing.ts, + source: { + messageId: existing.id, + author: existing.author as ConversationPlan.SourceAuthor, + quote: text, + start: 0, + end: text.length, + role: "question", + }, + question: text, + }, existing); + let target = targetKind === "same" ? threadId : "none"; + let output = await interpretLowOwnership(`repeat-audit-${id}`, text, { + weakFragments: false, + state, + triageTarget: target, + candidateThreadTarget: target, + }); + expect(output.events.some(event => event.type === "thread.opened")).toBe(false); + }, +); + +test.each(repeatQuestionCases)( + "a repeated audit question repairs only missing options on an $id despite a new target", + async ({ id, existing, missing, duplicates }) => { + let text = "Should audit logs go in PostgreSQL or object storage?"; + let threadId = "thread:existing-audit"; + let state = stateWithQuestion(threadId, text, existing); + let output = await interpretLowOwnership(`repeat-audit-${id}`, text, { + weakFragments: false, + state, + triageTarget: "new", + candidateThreadTargets: { c0_thread: "new", c1_thread: "new" }, + candidateRoles: { c0_role: "question", c1_role: "option" }, + candidateDuplicates: duplicates, + }); + let options = output.events.flatMap(event => event.type === "option.added" ? [event] : []); + expect(output.analysis.passes[0]?.answers.thread_target).toMatchObject({ + type: "choice", + choice: "new", + }); + expect(output.events.some(event => event.type === "thread.opened")).toBe(false); + expect(options.map(event => event.contribution.text)).toEqual([...missing]); + expect(options.every(event => event.threadId === threadId)).toBe(true); + expect(options.map(event => ({ + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + }))).toEqual(missing.map(option => { + let start = text.indexOf(option); + return { quote: option, start, end: start + option.length }; + })); + }, +); + +test.each([ + { id: "matching-thread-target", target: "existing" }, + { id: "mistaken-new-target", target: "new" }, +])( + "a low new-question score still recovers an empty exact match with $id", + async ({ id, target }) => { + let text = "Should audit logs go in PostgreSQL or object storage?"; + let options = ["PostgreSQL", "object storage"]; + let threadId = "thread:existing-audit-low-score"; + let state = stateWithQuestion(threadId, text); + let targetId = target === "existing" ? threadId : "new"; + let output = await interpretLowOwnership(`repeat-audit-low-${id}`, text, { + state, + triageTarget: targetId, + newQuestion: 0.5, + candidateThreadTargets: { c0_thread: targetId, c1_thread: targetId }, + }); + let additions = output.events.flatMap(event => event.type === "option.added" ? [event] : []); + expect(output.analysis.passes[0]?.answers.new_question).toMatchObject({ + type: "noul", + noul: 0.5, + }); + expect(output.analysis.passes[0]?.answers.act).toMatchObject({ + type: "choice", + choice: "question", + }); + expect(output.events.map(event => event.type)).toEqual([ + "option.added", + "option.added", + ]); + expect(additions.map(event => event.contribution.text)).toEqual(options); + expect(additions.every(event => event.threadId === threadId)).toBe(true); + expect(additions.map(event => ({ + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + }))).toEqual(options.map(option => { + let start = text.indexOf(option); + return { quote: option, start, end: start + option.length }; + })); + }, +); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-unique.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-unique.test.ts new file mode 100644 index 00000000..c352f230 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-unique.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { interpretMessage } from "./interpret"; +import { mockResult, seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("a new question keeps two unique quoted options when a third repeats one", async () => { + let current = message( + "repeat-option", + "Should we use Auth0? Or GitHub auth? Or GitHub auth?", + ); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: initialState(), + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { new_question: 0.97, act: "question", thread_target: "new", significance: 2 } + : { + c0_role: "option", + c0_thread: "new", + c0_new_option: 0.95, + c1_role: "option", + c1_thread: "none", + c1_new_option: 0.95, + c2_role: "option", + c2_thread: "none", + c2_new_option: 0.95, + }, + ), + }); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + ]); + expect( + output.events.flatMap(event => event.type === "option.added" ? [event.source?.quote] : []), + ).toEqual([ + "Should we use Auth0?", + "Or GitHub auth?", + ]); + expect(output.analysis.outcomes?.[2]?.gate).toBe("duplicate option in new question"); +}); + +test("multi-option grouping leaves options for an existing thread on that thread", async () => { + let state = seeded(); + let current = message("existing-options", "Use Auth0. Use custom login."); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { new_question: 0.97, act: "question", thread_target: "new", significance: 2 } + : { + c0_role: "option", + c0_thread: "thread-a", + c0_new_option: 0.95, + c1_role: "option", + c1_thread: "thread-a", + c1_new_option: 0.95, + }, + ), + }); + expect(output.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(output.events.map(event => event.threadId)).toEqual(["thread-a", "thread-a"]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-recovery.test-fixtures.ts new file mode 100644 index 00000000..1676f8ac --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery.test-fixtures.ts @@ -0,0 +1,172 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +export let interpretLowOwnership = ( + id: string, + text: string, + options: { + weakFragments?: boolean; + state?: ConversationPlan.State; + triageTarget?: string; + newQuestion?: number; + candidateThreadTarget?: string; + candidateThreadTargets?: Record; + candidateRoles?: Record; + candidateDuplicates?: Record; + } = {}, +) => { + let { + weakFragments = true, + state = initialState(), + triageTarget = "new", + newQuestion = 0.97, + candidateThreadTarget, + candidateThreadTargets, + candidateRoles, + candidateDuplicates, + } = options; + return interpretMessage({ + channelId: "channel", + message: message(`low-ownership-${id}`, text, "Jules"), + recent: [], + state, + ask: async request => { + if ("new_question" in request.questions) { + let overrides: Record = { + new_question: newQuestion, + new_option: 0.36, + act: "question", + thread_target: triageTarget, + significance: 2, + }; + for (let key of Object.keys(request.questions)) { + if (key.endsWith("_owned_unretracted")) overrides[key] = 0.6; + } + return mockResult(request.questions, overrides); + } + let overrides: Record = {}; + for (let key of Object.keys(request.questions)) { + if (!key.startsWith("c")) continue; + if (key.endsWith("_role")) { + overrides[key] = candidateRoles?.[key] + ?? (weakFragments && key.startsWith("c0_") ? "none" : "option"); + } + if (key.endsWith("_thread")) { + overrides[key] = candidateThreadTargets?.[key] + ?? (weakFragments && key.startsWith("c0_") + ? "none" + : candidateThreadTarget ?? "new"); + } + if (key.endsWith("_new_option")) { + overrides[key] = weakFragments && key.startsWith("c0_") ? 0.7 : 0.95; + } + if (key.endsWith("_duplicate") && candidateDuplicates?.[key] !== undefined) { + overrides[key] = candidateDuplicates[key]!; + } + } + let result = mockResult(request.questions, overrides); + let firstRole = result.answers.c0_role; + let roleQuestion = request.questions.c0_role; + if ( + weakFragments && firstRole?.type === "choice" && roleQuestion?.type === "choice" + ) { + let probabilities = Object.fromEntries( + Object.keys(roleQuestion.criteria).map(key => [ + key, + key === "none" ? 0.55 : key === "option" ? 0.45 : 0, + ]), + ); + result.answers.c0_role = { + type: "choice", + choice: "none", + confidence: 0.45, + probabilities, + }; + } + return result; + }, + }); +}; + +export let stateWithQuestion = ( + threadId: string, + text: string, + options: readonly string[] = [], + state = initialState(), +) => { + let original = message(`existing-${threadId}`, text); + state = applyInference(state, { + id: `existing-${threadId}-question`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: original.ts, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: text, + start: 0, + end: text.length, + role: "question", + }, + question: text, + }, original); + for (let [index, option] of options.entries()) { + let start = text.indexOf(option); + state = applyInference(state, { + id: `existing-${threadId}-option-${index}`, + type: "option.added", + threadId, + observedThreadVersion: state.threads.find(thread => thread.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: original.ts, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: option, + start, + end: start + option.length, + role: "option", + }, + contribution: { + id: `existing-${threadId}-option-${index}`, + text: option, + authoring: "quoted", + targetId: threadId, + }, + }, original); + } + return state; +}; + +export let repeatQuestionCases: Array<{ + id: string; + existing: string[]; + missing: string[]; + duplicates: Record; +}> = [ + { + id: "empty-card", + existing: [], + missing: ["PostgreSQL", "object storage"], + duplicates: {}, + }, + { + id: "partial-card", + existing: ["PostgreSQL"], + missing: ["object storage"], + duplicates: { c0_duplicate: 0.95 }, + }, + { + id: "complete-card", + existing: ["PostgreSQL", "object storage"], + missing: [], + duplicates: { c0_duplicate: 0.95, c1_duplicate: 0.95 }, + }, +]; diff --git a/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts b/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts new file mode 100644 index 00000000..1c49f17d --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-boundary.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { askJev } from "./jev"; +import { question, reply, validAnswers } from "./pipeline-reply.test-fixtures"; + +test("retains complete validated distributions and resolved model", async () => { + let result = await askJev({ state: { current: { text: "Synthetic" } }, questions: question }, { + apiKey: "test-only", + fetch: async () => reply(validAnswers), + }); + expect(result.model).toBe("jev-1.13.0"); + expect((result.answers.act as any).probabilities).toEqual({ yes: 0.9, no: 0.1 }); + expect((result.answers.level as any).probabilities).toEqual({ "0": 0.1, "1": 0, "2": 0.9 }); +}); + +test.each( + [ + [{ ...validAnswers, flag: { type: "noul", noul: Number.NaN } }, "invalid"], + [{ ...validAnswers, act: { ...validAnswers.act, probabilities: { yes: 1 } } }, "invalid"], + [{ ...validAnswers, act: { ...validAnswers.act, choice: "missing" } }, "invalid"], + [{ ...validAnswers, level: { ...validAnswers.level, score: 500 } }, "invalid"], + [{ + ...validAnswers, + level: { ...validAnswers.level, legend: { "0": "Wrong", "1": "Some", "2": "All" } }, + }, "invalid"], + [{ ...validAnswers, extra: { type: "noul", noul: 0.5 } }, "invalid"], + ] as const, +)("rejects malformed answer %#", async (answers, error) => { + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + fetch: async () => reply(answers), + })).rejects.toThrow(error); +}); + +test("bounds errors and aborts a stalled request", async () => { + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + fetch: async () => new Response("secret body", { status: 503 }), + })).rejects.toThrow("503"); + let abort = new AbortController(); + abort.abort(); + await expect(askJev({ state: "Synthetic", questions: question }, { + apiKey: "test-only", + signal: abort.signal, + fetch: async (_, init) => { + if (init?.signal?.aborted) throw new Error("aborted"); + return reply(validAnswers); + }, + })).rejects.toThrow(); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts b/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts new file mode 100644 index 00000000..ecdf919f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { askJev, type JevQuestion } from "./jev"; +import { + buildCandidateTargetingRequest, + buildTargetingRequest, + buildTriageRequest, +} from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult, seeded, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { reply } from "./pipeline-reply.test-fixtures"; + +test("twelve populated targets fit Jev's state budget with three long quotes", async () => { + let template = withOption(seeded()).threads[0]; + let threads: ConversationPlan.Thread[] = Array.from({ length: 12 }, (_, index) => { + let options = Array.from({ length: 8 }, (_, optionIndex) => ({ + ...template.contributions[0], + id: `option-${index}-${optionIndex}-${"x".repeat(12)}`, + text: "o".repeat(500), + })); + let reasons = Array.from({ length: 12 }, (_, reasonIndex) => ({ + ...template.contributions[0], + id: `reason-${index}-${reasonIndex}-${"x".repeat(12)}`, + kind: "reason" as const, + text: "r".repeat(500), + targetId: options[0].id, + sources: [{ ...template.contributions[0].sources[0], role: "reason" as const }], + })); + return { + ...template, + id: `thread-${index}`, + question: "Q".repeat(500), + status: index === 11 ? "discarded" : "exploring", + contributions: [...options, ...reasons], + pendingSettle: index === 0 + ? { optionId: options[0].id, proposer: "Mina", messageId: "proposal" } + : undefined, + stances: Array.from({ length: 20 }, (_, stanceIndex) => ({ + id: `stance-${index}-${stanceIndex}`, + participant: `member-${stanceIndex}`, + optionId: options[0].id, + position: "support" as const, + sources: [{ ...template.contributions[0].sources[0], role: "support" as const }], + at: 1000, + })), + }; + }); + let current = message( + "dense", + ["A", "B", "C"].map((letter) => `Consider ${letter} ${"x".repeat(1230)}.`).join(" "), + ); + let recent = Array.from( + { length: 12 }, + (_, index) => message(`recent-${index}`, "r".repeat(300)), + ); + let candidates = extractQuotes(current.text); + expect(candidates).toHaveLength(3); + let triage = buildTriageRequest(current, recent, threads); + let targeting = buildTargetingRequest(current, recent, threads, candidates); + let isolated = buildCandidateTargetingRequest(current, recent, threads, candidates, 0); + expect((isolated.state as { recent: Array<{ id: string }> }).recent.map(entry => entry.id)) + .toEqual(recent.map(entry => entry.id)); + let context = (targeting.state as { + threads: Array<{ + id: string; + options: Array<{ id: string }>; + pendingSettle?: { optionId: string; option: string; proposer: string }; + }>; + }).threads; + let targetChoices = + (targeting.questions.c0_thread as Extract).criteria; + let optionChoices = + (targeting.questions.c0_chosen_option as Extract).criteria; + expect(context.map((thread) => thread.id)).toEqual( + Object.keys(targetChoices).filter((id) => id !== "new" && id !== "none"), + ); + expect(context.flatMap((thread) => thread.options.map((option) => option.id)).sort()).toEqual( + Object.keys(optionChoices).filter((id) => id !== "new" && id !== "none").sort(), + ); + expect(context[0].pendingSettle).toEqual({ + optionId: threads[0].pendingSettle!.optionId, + option: "o".repeat(100), + proposer: "Mina", + }); + expect(targeting.questions.c0_agrees_with_settle?.type).toBe("noul"); + expect(Object.keys(targeting.questions)).toHaveLength(42); + expect(Object.keys(targeting.questions).filter(key => key.endsWith("_duplicate"))) + .toEqual([]); + expect(context[11].options.length).toBeGreaterThan(0); + for (let request of [triage, targeting]) { + expect((request.state as { threads: unknown[] }).threads).toHaveLength(12); + expect(JSON.stringify(request.state).length).toBeLessThanOrEqual(24_000); + let answer = await askJev(request, { + apiKey: "test-only", + fetch: async () => reply(mockResult(request.questions, {}).answers), + }); + expect(answer.model).toBe("jev-1.13.0"); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts b/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts new file mode 100644 index 00000000..2d51e742 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { initialState, restoreState } from "./domain"; +import { askJev } from "./jev"; +import { buildTargetingRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult, seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { reply } from "./pipeline-reply.test-fixtures"; + +test("keeps the expanded three-quote request within a 45-question bound", async () => { + let questions = Object.fromEntries( + Array.from({ length: 46 }, (_, index) => [ + `q${index}`, + { type: "noul" as const, instructions: "Is this true?" }, + ]), + ); + let calls = 0; + let ask = (count: number) => { + let selected = Object.fromEntries(Object.entries(questions).slice(0, count)); + return askJev({ state: {}, questions: selected }, { + apiKey: "test-only", + fetch: async () => { + calls++; + return reply(mockResult(selected, {}).answers); + }, + }); + }; + expect(Object.keys((await ask(45)).answers)).toHaveLength(45); + expect(calls).toBe(1); + await expect(ask(46)).rejects.toThrow("invalid Jev request bounds"); + expect(calls).toBe(1); +}); + +test("three discarded-thread quotes fit the request and persisted answer bounds", async () => { + let current = message("three-quotes", "Revisit A. Revisit B. Revisit C."); + let threads = [{ ...seeded().threads[0], status: "discarded" as const }]; + let request = buildTargetingRequest(current, [], threads, extractQuotes(current.text)); + expect(Object.keys(request.questions)).toHaveLength(39); + expect(Object.keys(request.questions).filter(key => key.endsWith("_duplicate"))).toEqual([]); + let result = await askJev(request, { + apiKey: "test-only", + fetch: async () => reply(mockResult(request.questions, {}).answers), + }); + expect(Object.keys(result.answers)).toHaveLength(39); + let state = initialState(); + state.analysis.push({ + messageId: current.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: result.model, + status: "unlinked", + eventIds: [], + passes: [{ stage: "targeting", answers: result.answers }], + }); + expect(restoreState(state).analysis[0].passes[0].answers).toEqual(result.answers); + let oversized = structuredClone(state); + for (let index = 0; index < 7; index++) { + oversized.analysis[0].passes[0].answers[`extra${index}`] = { + type: "noul", + noul: 0.5, + }; + } + expect(() => restoreState(oversized)).toThrow("too many analysis questions"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-named.test.ts b/apps/server/src/conversation-plan/pipeline-reply-named.test.ts new file mode 100644 index 00000000..14853a61 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-named.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +test.each([ + { + name: "When I Work", + text: "We could use When I Work, Deputy, or Homebase.", + options: ["When I Work", "Deputy", "Homebase"], + }, + { + name: "Asana", + text: "We could use Asana, Linear, or Trello.", + options: ["Asana", "Linear", "Trello"], + }, +])("a named three-option list can start with $name", ({ name, text, options }) => { + let current = message(`named-options-${name}`, text, "Jules"); + let candidates = extractQuotes(text); + expect(candidates.map(candidate => candidate.quote)).toEqual([...options]); + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: candidates.map(candidate => ({ + ...candidate, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "option.added", + ]); + expect( + result.events.slice(1).map(event => + event.type === "option.added" ? event.contribution.text : "" + ), + ).toEqual([...options]); +}); + +test("multi-option grouping refuses a question longer than the event text bound", () => { + let current = message( + "long-options", + `${"Background ".repeat(51)}Use Auth0. Use GitHub auth.`, + ); + let quotes = ["Use Auth0.", "Use GitHub auth."]; + let candidates = quotes.map(quote => ({ + quote, + start: current.text.indexOf(quote), + end: current.text.indexOf(quote) + quote.length, + answers: follow({ role: "option", thread: "none" }), + })); + expect(current.text.length).toBeGreaterThan(500); + let triage = mockResult(buildTriageRequest(current, [], []).questions, { + new_question: 0.97, + act: "question", + thread_target: "new", + }).answers; + expect( + planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates, + }).events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-options.test.ts b/apps/server/src/conversation-plan/pipeline-reply-options.test.ts new file mode 100644 index 00000000..720b31c9 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-options.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test.each([ + { + id: "uploaded-files", + text: "Should we store uploaded files in Amazon S3 or on a local disk?", + options: ["Amazon S3", "on a local disk"], + }, + { + id: "notification-emails", + text: "Which service should send notification emails: Postmark or Amazon SES?", + options: ["Postmark", "Amazon SES"], + }, + { + id: "d03-notification-providers", + text: "what sends transactional notifications? our SMTP relay, Postmark, or SES?", + options: ["SMTP relay", "Postmark", "SES"], + }, +])("extracts only the exact alternatives from $id question", async ({ id, text, options }) => { + let current = message(`explicit-alternatives-${id}`, text, "Jules"); + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: initialState(), + ask: async request => { + if ("new_question" in request.questions) { + return mockResult(request.questions, { + new_question: 0.97, + act: "question", + thread_target: "new", + significance: 2, + }); + } + let overrides: Record = {}; + for (let key of Object.keys(request.questions)) { + if (!key.startsWith("c")) continue; + if (key.endsWith("_role")) overrides[key] = "option"; + if (key.endsWith("_thread")) overrides[key] = "new"; + if (/^c\d+_option$/.test(key)) overrides[key] = "new"; + if (key.endsWith("_new_option")) overrides[key] = 0.95; + } + return mockResult(request.questions, overrides); + }, + }); + + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + ...options.map(() => "option.added" as const), + ]); + expect(output.events[0]).toMatchObject({ + question: text, + source: { quote: text, start: 0, end: text.length, role: "question" }, + }); + let optionEvents = output.events.slice(1); + expect(optionEvents.map(event => event.type === "option.added" ? event.contribution.text : "")) + .toEqual([...options]); + expect(optionEvents.map(event => + event.type === "option.added" + ? { + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + } + : undefined + )) + .toEqual(options.map(option => { + let start = text.indexOf(option); + return { quote: option, start, end: start + option.length }; + })); +}); + +test("D03's direct option group requires option=new for every candidate", async () => { + let text = "what sends transactional notifications? our SMTP relay, Postmark, or SES?"; + let current = message("d03-missing-option-target", text, "Jules"); + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state: initialState(), + ask: async request => { + if ("new_question" in request.questions) { + return mockResult(request.questions, { + new_question: 0.97, + act: "question", + thread_target: "new", + significance: 2, + }); + } + let overrides: Record = {}; + for (let key of Object.keys(request.questions)) { + if (!key.startsWith("c")) continue; + if (key.endsWith("_role")) overrides[key] = "option"; + if (key.endsWith("_thread")) overrides[key] = "new"; + let optionTarget = key.match(/^c(\d+)_option$/); + if (optionTarget) overrides[key] = optionTarget[1] === "1" ? "none" : "new"; + if (key.endsWith("_new_option")) overrides[key] = 0.95; + } + return mockResult(request.questions, overrides); + }, + }); + expect(output.events.filter(event => event.type === "option.added")).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts b/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts new file mode 100644 index 00000000..9e0a8054 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-restore.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { initialState, restoreState } from "./domain"; +import { validAnswers } from "./pipeline-reply.test-fixtures"; + +test("JSON restore keeps typed answers and rejects unbounded legends", () => { + let state = initialState(); + state.analysis.push({ + messageId: "synthetic", + questionSetVersion: "v1", + modelVersion: "jev-1.13.0", + status: "unlinked", + eventIds: [], + passes: [{ stage: "triage", answers: structuredClone(validAnswers) as any }], + }); + let restored = restoreState(JSON.parse(JSON.stringify(state))); + expect((restored.analysis[0].passes[0].answers.level as any).legend["2"]).toBe("All"); + let unbounded = structuredClone(state); + (unbounded.analysis[0].passes[0].answers.level as any).legend["2"] = "x".repeat(501); + expect(() => restoreState(unbounded)).toThrow("legend"); + let malformed = structuredClone(state); + (malformed.analysis[0].passes[0].answers.act as any).probabilities.yes = Infinity; + expect(() => restoreState(malformed)).toThrow("probability"); + let bounded = structuredClone(state); + bounded.analysis[0].outcomes = [{ + start: 0, + end: 9, + status: "review", + gate: "unclear", + eventIds: [], + }]; + expect(restoreState(JSON.parse(JSON.stringify(bounded))).analysis[0].outcomes?.[0].gate) + .toBe("unclear"); + bounded.analysis[0].outcomes[0].gate = "x".repeat(301); + expect(() => restoreState(bounded)).toThrow("candidate outcome"); +}); + +test("restores exactly 45 analysis answers and rejects a 46th", () => { + let state = initialState(); + let answers = Object.fromEntries(Array.from({ length: 46 }, (_, index) => [ + `q${index}`, + { type: "noul" as const, noul: 0.5 }, + ])); + state.analysis.push({ + messageId: "bounded", + questionSetVersion: "v4", + modelVersion: "jev-1.13.0", + status: "unlinked", + eventIds: [], + passes: [{ + stage: "targeting", + answers: Object.fromEntries( + Object.entries(answers).slice(0, 45), + ), + }], + }); + expect(Object.keys( + restoreState(JSON.parse(JSON.stringify(state))).analysis[0].passes[0] + .answers, + )).toHaveLength(45); + state.analysis[0].passes[0].answers = answers; + expect(() => restoreState(JSON.parse(JSON.stringify(state)))).toThrow( + "too many analysis questions", + ); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts b/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts new file mode 100644 index 00000000..3148bab1 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-unsafe.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { extractQuotes } from "./quotes"; + +test.each( + [ + [ + "capitalized because qualifier", + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "capitalized when-configured qualifier", + "When configured, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "capitalized as-fallback qualifier", + "As Fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "quoted", + 'what sends transactional notifications? "our SMTP relay, Postmark, or SES?"', + ], + [ + "reported", + 'Dan said, "what sends transactional notifications? our SMTP relay, Postmark, or SES?"', + ], + [ + "negated", + "not what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "conditional", + "if cost wins, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "inline conditional qualifier", + "what sends transactional notifications if cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "inline when qualifier", + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "inline topic attribution", + "what sends transactional notifications according to Dan? our SMTP relay, Postmark, or SES?", + ], + [ + "reported inline topic", + "what sends transactional notifications as Dan reported? our SMTP relay, Postmark, or SES?", + ], + [ + "inline unless qualifier", + "what sends transactional notifications unless cost wins? our SMTP relay, Postmark, or SES?", + ], + [ + "reported inline question", + "Dan said what sends transactional notifications? our SMTP relay, Postmark, or SES?", + ], + [ + "slash-conjoined providers", + "what sends transactional notifications? our SMTP relay / Postmark, SES, or Mailgun?", + ], + [ + "rationale in first item", + "what sends transactional notifications? our SMTP relay is cheap, Postmark, or SES?", + ], + [ + "because rationale in first item", + "what sends transactional notifications? our SMTP relay because cheap, Postmark, or SES?", + ], + [ + "capitalized because list item", + "what sends transactional notifications? our SMTP relay, Because, or SES?", + ], + [ + "capitalized when-configured list item", + "what sends transactional notifications? our SMTP relay, When configured, or SES?", + ], + [ + "capitalized as-fallback list item", + "what sends transactional notifications? our SMTP relay, As Fallback, or SES?", + ], + [ + "duplicate-item", + "what sends transactional notifications? our SMTP relay, Postmark, or SMTP relay?", + ], + ] as const, +)("does not split an unsafe D03 provider list into choices", (_, text) => { + let quotes = extractQuotes(text); + expect(quotes.some(({ quote }) => + [ + "SMTP relay", + "Postmark", + "SES", + "Because", + "When configured", + "As Fallback", + ].includes(quote) + )) + .toBe(false); +}); + +test.each([ + `Rob said, "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model."`, + `"Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model."`, + "Not Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Tiptap, bare ProseMirror, Lexical, or Tiptap.", +])("quoted, negated, or duplicate four-option lists stay whole: %s", text => { + expect(extractQuotes(text)).not.toHaveLength(4); +}); + +test("a fifth explicit option fails the source budget", () => { + expect(() => + extractQuotes( + "Tiptap, bare ProseMirror, Lexical, Slate, or just native Selection and Range with our own document model.", + ) + ).toThrow("source quote count exceeds 4"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts new file mode 100644 index 00000000..bd8240e9 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts @@ -0,0 +1,25 @@ +import type { JevQuestion } from "./jev"; + +export let question: Record = { + flag: { type: "noul", instructions: "Does current.text ask?" }, + act: { type: "choice", instructions: "What act?", criteria: { yes: "Yes", no: "No" } }, + level: { type: "score", instructions: "How much?", criteria: ["None", "Some", "All"] }, +}; + +export let validAnswers = { + flag: { type: "noul", noul: 0.8 }, + act: { type: "choice", choice: "yes", confidence: 0.9, probabilities: { yes: 0.9, no: 0.1 } }, + level: { + type: "score", + score: 1.8, + confidence: 0.8, + legend: { "0": "None", "1": "Some", "2": "All" }, + probabilities: { "0": 0.1, "1": 0, "2": 0.9 }, + }, +}; + +export let reply = (answers: unknown) => + new Response( + JSON.stringify({ model: "jev-1.13.0", answers, usage: { input_tokens: 10, output_tokens: 2 } }), + { status: 200 }, + ); diff --git a/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts new file mode 100644 index 00000000..4d3a69ee --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts @@ -0,0 +1,154 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import { applyInference } from "./domain"; +import { planEvents, type PolicyInput } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +export function d01SeedScopedChoiceProposal( + setup: ReturnType, + proposalId = "d01-m6-scoped-proposal", + messageId = "d01-m6", +) { + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let sourceMessage = message( + messageId, + `agreed. ${quote} still want to see how it handles pasted tables.`, + "Mei", + ); + let proposal: ConversationPlan.Event = { + id: proposalId, + type: "scoped-choice.proposed", + threadId: setup.threadId, + observedThreadVersion: setup.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: sourceMessage.ts, + source: { + messageId: sourceMessage.id, + author: sourceMessage.author as ConversationPlan.SourceAuthor, + quote, + start: 8, + end: 39, + role: "support", + }, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: lexicalId, + label: "Lexical", + scope: "spike", + }; + let state = applyInference(setup.state, proposal, sourceMessage); + return { ...setup, state, proposal, lexicalId }; +} + +export function d01RobM7PolicyResult( + setup: ReturnType, + options: { + state?: ConversationPlan.State; + author?: string; + text?: string; + owned?: number; + optionId?: string; + messageId?: string; + } = {}, +) { + let threadId = setup.threadId; + let selectedOptionId = options.optionId ?? "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let text = options.text ?? "yep, Lexical for the spike. not a final library call yet."; + let current = { + ...message(options.messageId ?? "d01-m7", text, options.author ?? "Rob"), + ts: 1001, + }; + let quotes = extractQuotes(text); + let firstPass: Record = { + ...first({ new_question: 0.14, enough_purpose: 0.93, new_option: 0.33, support: 0.93 }), + c0_owned_unretracted: { type: "noul", noul: options.owned ?? 0.89 }, + c1_owned_unretracted: { type: "noul", noul: 0.93 }, + act: { + type: "choice" as const, + choice: "commitment", + confidence: 0.4, + probabilities: { + question: 0, + proposal: 0.06, + evaluation: 0.17, + commitment: 0.5, + correction: 0.23, + other: 0.04, + }, + }, + thread_target: { + type: "choice" as const, + choice: threadId, + confidence: 0.92, + probabilities: { [threadId]: 0.95, new: 0, none: 0.05 }, + }, + }; + let optionProbabilities = Object.fromEntries( + setup.options.map(option => [option.id, option.id === selectedOptionId ? 0.54 : 0]), + ); + optionProbabilities.none = 0.46; + let chosenProbabilities = Object.fromEntries( + setup.options.map(option => [option.id, option.id === selectedOptionId ? 0.63 : 0]), + ); + chosenProbabilities.none = 0.37; + let recordedChoices: Record = { + option: { + type: "choice", + choice: selectedOptionId, + confidence: 0.46, + probabilities: optionProbabilities, + }, + chosen_option: { + type: "choice", + choice: selectedOptionId, + confidence: 0.56, + probabilities: chosenProbabilities, + }, + }; + let candidates: PolicyInput["candidates"] = quotes.map((quote, index) => ({ + ...quote, + answers: index === 0 + ? { + ...follow({ role: "support", thread: threadId }), + ...recordedChoices, + role: { + type: "choice", + choice: "support", + confidence: 0.59, + probabilities: { + question: 0, + option: 0.16, + reason: 0, + constraint: 0, + support: 0.63, + objection: 0, + resolution: 0.08, + reopening: 0, + none: 0.13, + }, + }, + thread: { + type: "choice", + choice: threadId, + confidence: 0.99, + probabilities: { [threadId]: 1, new: 0, none: 0 }, + }, + relation: confidentChoice("supports"), + support: { type: "noul", noul: 0.93 }, + duplicate: { type: "noul", noul: 0.64 }, + } + : { ...follow({ role: "none", thread: threadId }) }, + })); + let result = planEvents({ + channelId: "channel", + message: current, + state: options.state ?? setup.state, + linkedCards: setup.linkedCards, + first: firstPass, + candidates, + }); + return { current, quotes, result }; +} diff --git a/apps/server/src/conversation-plan/pipeline-source-fallback.test.ts b/apps/server/src/conversation-plan/pipeline-source-fallback.test.ts new file mode 100644 index 00000000..acfb7dd4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-fallback.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { optionIdFor, planEvents } from "./policy"; +import { buildCandidateTargetingRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, member, message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption } from "./policy-terminal.test-fixtures"; +import { hostingState } from "./pipeline-commitment.test-fixtures"; + +test("a sourced fallback condition qualifies the pending VPS choice only with a clear referent", () => { + let state = hostingState(); + let managedOptionId = optionIdFor("channel", "managed-hosting-option", 0, 1000); + state = applyEvent(state, { + id: "managed-hosting-option-event", + type: "option.added", + threadId: "hosting-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: member("Mina"), + at: 1000, + contribution: { + id: managedOptionId, + text: "Use managed hosting.", + authoring: "human-edited", + }, + }); + state = addThreadWithOption( + state, + "other-thread", + "Where should backups live?", + "backup-option", + "Keep backups on object storage.", + ); + let proposal = message("vps-proposal", "Let's host it on our own VPS.", "Mina"); + state = applyInference(state, { + id: "vps-pending-event", + type: "settle.suggested", + threadId: "hosting-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + optionId: "vps-option", + }, proposal); + let current = message( + "qualified-vps", + "yes, and keep managed hosting as the escape hatch if on-call gets silly", + "Jules", + ); + let quotes = extractQuotes(current.text); + let isolated = buildCandidateTargetingRequest( + current, + [proposal], + state.threads, + quotes, + 1, + state.events, + ); + expect(isolated.questions.c1_qualifies_pending_settle?.type).toBe("noul"); + let condition: Parameters[0]["candidates"][number] = { + ...quotes[1]!, + answers: { + ...follow({ role: "constraint", thread: "hosting-thread" }), + role: { + type: "choice" as const, + choice: "constraint", + confidence: 0.46, + probabilities: { constraint: 0.46, support: 0.3, option: 0.14, none: 0.1 }, + }, + option: confidentChoice("none"), + relation: { + type: "choice" as const, + choice: "qualifies", + confidence: 0.76, + probabilities: { qualifies: 0.76, supports: 0.18, unrelated: 0.06 }, + }, + planning_substance: { type: "noul" as const, noul: 0.91 }, + qualifies_pending_settle: { type: "noul" as const, noul: 0.91 }, + duplicate: { type: "noul" as const, noul: 0.08 }, + }, + }; + let triage: Record = { + ...first({ constraint: 0.9 }), + significance: { + type: "score", + score: 2, + confidence: 1, + legend: { "0": "none", "1": "minor", "2": "useful" }, + probabilities: { "0": 0, "1": 0, "2": 1 }, + }, + }; + let assess = ( + candidate = condition, + firstAnswers = triage, + candidateState = state, + ) => + planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: firstAnswers, + candidates: [candidate], + }); + let accepted = assess(); + expect(accepted.events.map(event => event.type)).toEqual(["constraint.added"]); + expect(accepted.events[0]).toMatchObject({ + threadId: "hosting-thread", + source: { ...quotes[1], role: "constraint" }, + contribution: { targetId: "vps-option", relation: "qualifies" }, + }); + let strongFallbackSupport = assess({ + ...condition, + answers: { + ...condition.answers, + role: confidentChoice("support"), + support: { type: "noul" as const, noul: 0.96 }, + option: confidentChoice(managedOptionId), + }, + }, { ...triage, support: { type: "noul", noul: 0.96 } }); + expect(strongFallbackSupport.events.map(event => event.type)).toEqual(["constraint.added"]); + expect(strongFallbackSupport.events[0]).toMatchObject({ + contribution: { targetId: "vps-option", relation: "qualifies" }, + }); + expect( + assess({ + ...condition, + answers: { + ...condition.answers, + option: confidentChoice("backup-option"), + }, + }).events[0], + ).toMatchObject({ contribution: { targetId: "vps-option" } }); + let guards: Record[] = [ + { thread: confidentChoice("none") }, + { relation: confidentChoice("unrelated") }, + { qualifies_pending_settle: { type: "noul" as const, noul: 0.4 } }, + { planning_substance: { type: "noul" as const, noul: 0.4 } }, + { duplicate: { type: "noul" as const, noul: 0.9 } }, + ]; + for (let answers of guards) { + expect(assess({ ...condition, answers: { ...condition.answers, ...answers } }).events) + .toEqual([]); + } + expect( + assess(condition, { + ...triage, + c0_owned_unretracted: { type: "noul", noul: 0.2 }, + }).events, + ).toEqual([]); + let stale = structuredClone(state); + stale.threads[0]!.pendingSettle = undefined; + expect(assess(condition, triage, stale).events).toEqual([]); + let withdrawal = message("vps-withdrawal", "I no longer support the VPS.", "Mina"); + let withdrawn = applyInference(state, { + id: "vps-withdrawal-event", + type: "stance.changed", + scopedProposalId: null, + threadId: "hosting-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: withdrawal.ts, + source: { + messageId: withdrawal.id, + author: withdrawal.author as ConversationPlan.SourceAuthor, + quote: withdrawal.text, + start: 0, + end: withdrawal.text.length, + role: "objection", + }, + optionId: "vps-option", + position: "oppose", + }, withdrawal); + expect(assess(condition, triage, withdrawn).events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-source-opening.test.ts b/apps/server/src/conversation-plan/pipeline-source-opening.test.ts new file mode 100644 index 00000000..b574a03b --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-opening.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { applyInference, initialState } from "./domain"; +import { effectsFor } from "./effects"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test.each( + [ + ["interrogative choice", "Should we use Auth0 or build our own authentication?", 0.97], + ["declarative choice", "We need to decide between Auth0 and a custom login system.", 0.93], + ["specific unknown", "How should we handle account recovery?", 0.65], + ] as const, +)("an actual %s can still open a card", async (_, text, newQuestion) => { + let current = message(`actual-${newQuestion}`, text); + let state = initialState(); + let calls = 0; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent: [], + state, + ask: async request => + mockResult( + request.questions, + calls++ === 0 + ? { enough_purpose: 0.98, new_question: newQuestion, significance: 2 } + : { c0_role: "question", c0_thread: "new" }, + ), + }); + expect(output.events.map(event => event.type)).toEqual(["thread.opened"]); + let accepted = applyInference(state, output.events[0]!, current); + expect(effectsFor(output.events, accepted).map(effect => effect.kind)).toContain( + "insert-card", + ); +}); diff --git a/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts b/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts new file mode 100644 index 00000000..d8cc4131 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { message } from "./policy-initial.test-fixtures"; + +test("extracts two exact clauses with UTF-16 offsets", () => { + let text = "Keep the outline optional; separately, should each repository remember my choice?"; + let quotes = extractQuotes(text); + expect(quotes).toHaveLength(2); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("splits one sentence with two imperative claims into source-bound clauses", () => { + let text = "Keep setup optional and remember my choice per repository."; + let quotes = extractQuotes(text); + expect(quotes.map((item) => item.quote)).toEqual([ + "Keep setup optional", + "remember my choice per repository.", + ]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("splits short assent from an and-keep condition with exact offsets", () => { + let text = "yes, and keep managed hosting as the escape hatch if on-call gets silly"; + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([ + "yes,", + "keep managed hosting as the escape hatch if on-call gets silly", + ]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); + expect(quotes[1]!.start).toBe(text.indexOf("keep")); +}); + +test.each( + [ + [ + "reported library choice", + "We could do tiptap or prosemirror or lexical for text string handling.", + ["tiptap", "prosemirror", "lexical"], + ], + [ + "held-out storage choice", + "We could use SQLite, Postgres, or DynamoDB for storing drafts.", + ["SQLite", "Postgres", "DynamoDB"], + ], + [ + "named topic", + "By agent access I mean Copilot or a hosted gateway or bring-your-own API keys.", + ["Copilot", "a hosted gateway", "bring-your-own API keys"], + ], + ] as const, +)("extracts atomic, exact alternatives from %s", (_, text, labels) => { + let current = message("three-way", text); + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([...labels]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); + let triage = buildTriageRequest(current, [], [], quotes); + let state = triage.state as { + current: { text: string }; + candidates: Array<{ quote: string; start: number; end: number }>; + }; + expect(state.current.text).toBe(text); + expect(state.candidates).toEqual(quotes); + for (let index = 0; index < quotes.length; index++) { + let targeting = buildCandidateTargetingRequest(current, [], [], quotes, index).state as { + current: { text: string }; + preceding: string; + }; + expect(targeting.current.text).toBe(quotes[index]!.quote); + expect(targeting.preceding).toBe(text.slice(0, quotes[index]!.start)); + } +}); + +test("extracts D03's three provider alternatives as exact UTF-16 spans", () => { + let text = "what sends transactional notifications? our SMTP relay, Postmark, or SES?"; + expect(extractQuotes(text)).toEqual([ + { quote: "SMTP relay", start: 44, end: 54 }, + { quote: "Postmark", start: 56, end: 64 }, + { quote: "SES", start: 69, end: 72 }, + ]); +}); + +test.each([ + "what sends transactional notifications when configured? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications as a fallback? our SMTP relay, Postmark, or SES?", +])("keeps qualified question text together for review: %s", text => { + expect(extractQuotes(text).map(({ quote }) => quote)).toEqual([ + text.slice(0, text.indexOf("?") + 1), + text.slice(text.indexOf("?") + 2), + ]); +}); + +test.each([ + "We could use a cache or logs or metrics and improve visibility.", + "The report says we could use SQLite or Postgres or DynamoDB for drafts.", + 'We could use "SQLite or Postgres or DynamoDB" for drafts.', + "We could use SQLite or Postgres or DynamoDB, but none has been approved.", + "We could use SQLite or Postgres or DynamoDB but none has been approved.", + "We could use SQLite or Postgres or DynamoDB none has been approved.", + "We could use SQLite or Postgres or DynamoDB or Redis for drafts.", + "We could use SQLite or SQLite or DynamoDB for drafts.", +])("does not split an unsafe or non-atomic list: %s", text => { + expect(extractQuotes(text)).toHaveLength(1); +}); + +test("keeps an unpunctuated contrastive suffix out of option sources", () => { + let text = "We could use SQLite or Postgres or DynamoDB but none has been approved."; + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); +}); + +test.each([ + "We could use SQLite or Postgres or DynamoDB because latency matters.", + "We could use SQLite or Postgres or DynamoDB since latency matters.", + "We could use SQLite or Postgres or DynamoDB as latency matters.", + "We could use SQLite or Postgres or DynamoDB for storing drafts because latency matters.", +])("keeps shared rationale out of the last alternative: %s", text => { + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts b/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts new file mode 100644 index 00000000..947f9729 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; + +test("D01 question recovery keeps purpose, source, and target guards", () => { + let input = d01RecordedOpening(); + let firstClause = input.candidates[0]!; + let purposeOnly = { + ...input, + message: { ...input.message, text: firstClause.quote }, + candidates: [firstClause], + }; + let unowned = structuredClone(input); + unowned.first.c1_owned_unretracted = { type: "noul", noul: 0.1 }; + let wrongQuote = structuredClone(input); + wrongQuote.candidates[1]!.quote = "what else are we comparing?"; + let weakQuestion = structuredClone(input); + weakQuestion.first.new_question = { type: "noul", noul: 0.1 }; + let reported = message("d01-reported", `Rob said, "${input.message.text}"`, "Nia"); + let reportedQuestion = { + ...input, + message: reported, + candidates: extractQuotes(reported.text).map((quote, index) => ({ + ...quote, + answers: input.candidates[index]!.answers, + })), + }; + let competingThread = { ...input, state: seeded() }; + for ( + let guarded of [ + purposeOnly, + unowned, + wrongQuote, + weakQuestion, + reportedQuestion, + competingThread, + ] + ) { + expect(planEvents(guarded).events).toEqual([]); + } +}); + +test("D01 recovery does not turn a confident first-clause resolution into a question", () => { + let input = d01RecordedOpening(); + let current = { + ...input.message, + text: "need to pick Lexical before comments get any deeper. what are we comparing?", + }; + let candidates = extractQuotes(current.text).map((quote, index) => ({ + ...quote, + answers: structuredClone(input.candidates[index]!.answers), + })); + candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.95, + probabilities: { resolution: 0.95, question: 0.01, none: 0.04 }, + }; + expect(planEvents({ ...input, message: current, candidates }).events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-source-targeting.test.ts b/apps/server/src/conversation-plan/pipeline-source-targeting.test.ts new file mode 100644 index 00000000..ece4eebe --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-targeting.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { interpretMessage } from "./interpret"; +import { buildTargetingRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult, seeded, settledBy, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("active targets after the eighth keep their options in choices and compact context", () => { + let template = withOption(seeded()).threads[0]; + let discarded = { ...template, id: "discarded-first", status: "discarded" as const }; + let active = Array.from({ length: 12 }, (_, index) => ({ + ...template, + id: `thread-${index}`, + question: `Question ${index}?`, + contributions: [{ ...template.contributions[0], id: `option-${index}` }], + })); + let current = message("late-target", "Let's settle option eleven."); + let request = buildTargetingRequest( + current, + [], + [discarded, ...active], + extractQuotes(current.text), + ); + let threadChoices = + (request.questions.c0_thread as Extract).criteria; + let optionChoices = + (request.questions.c0_chosen_option as Extract).criteria; + expect(threadChoices["thread-11"]).toContain("Question 11"); + expect(threadChoices["discarded-first"]).toBeUndefined(); + expect(optionChoices["option-11"]).toContain("Question 11"); + let context = + (request.state as { threads: Array<{ id: string; options: Array<{ id: string }> }> }) + .threads; + expect(context.map((thread) => thread.id)).toHaveLength(12); + expect(context.find((thread) => thread.id === "thread-11")?.options.map((option) => option.id)) + .toContain("option-11"); +}); + +test("four isolated targets persist a pending qualifier relation within 45 answers", async () => { + let current = message( + "figma-mixed", + "Sounds good to me. What about optional outlines if editors can change them? Copilot? BYO API keys?", + "Jules", + ); + let recent = [ + message("purpose", "We need to figure out auth.", "Mina"), + message("alternatives", "Auth0, custom login, or GitHub auth?", "Jules"), + message("suggestion", "GitHub could work.", "Mina"), + message("proposal", "Let's just go with GitHub.", "Mina"), + ]; + let calls: Array<{ questions: string[]; state: unknown }> = []; + let output = await interpretMessage({ + channelId: "channel", + message: current, + recent, + state: settledBy(seeded(), "Mina"), + ask: async request => { + let keys = Object.keys(request.questions); + calls.push({ questions: keys, state: request.state }); + let overrides: Record = keys.includes("new_question") + ? { + new_question: 0.95, + support: 0.9, + significance: 2, + c0_owned_unretracted: 0.95, + c1_owned_unretracted: 0.95, + c2_owned_unretracted: 0.95, + } + : keys.some(key => key.startsWith("c0_")) + ? { + c0_role: "support", + c0_thread: "thread-a", + c0_support: 0.95, + c0_agrees_with_settle: 0.95, + } + : keys.some(key => key.startsWith("c1_")) + ? { c1_role: "question", c1_thread: "new" } + : {}; + return mockResult(request.questions, overrides); + }, + }); + expect(calls).toHaveLength(5); + expect(calls[0]!.questions).toContain("new_question"); + for (let index = 0; index < 4; index++) { + expect(calls[index + 1]!.questions.every(key => key.startsWith(`c${index}_`))) + .toBe(true); + expect((calls[index + 1]!.state as { recent: Array<{ id: string }> }).recent + .map(entry => entry.id)).toEqual(recent.map(entry => entry.id)); + } + expect(calls[2]!.questions).toContain("c1_relation"); + expect(calls[2]!.questions).toContain("c1_qualifies_pending_settle"); + expect(output.analysis.passes.map(pass => pass.stage)).toEqual(["triage", "targeting"]); + expect(calls.slice(1).flatMap(call => call.questions).filter(key => key.endsWith("_duplicate"))) + .toEqual([]); + expect(Object.keys(output.analysis.passes[1]!.answers)).toHaveLength(44); + expect(output.events.map(event => event.type)).toEqual([ + "stance.changed", + "settle.agreed", + "thread.opened", + ]); + let quotes = extractQuotes(current.text); + expect(output.events.map(event => "source" in event ? event.source?.quote : "")) + .toEqual([quotes[0]!.quote, quotes[0]!.quote, quotes[1]!.quote]); + expect(output.events.every(event => + "source" in event + && event.source?.messageId === current.id + && current.text.slice(event.source.start, event.source.end) === event.source.quote + )) + .toBe(true); +}); diff --git a/apps/server/src/conversation-plan/planner-job-interruption.ts b/apps/server/src/conversation-plan/planner-job-interruption.ts new file mode 100644 index 00000000..beb33c45 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-interruption.ts @@ -0,0 +1,47 @@ +import * as Jobs from "./jobs"; +import type { Job } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; + +/** Settle a stopped coordinator's running job before reusing its in-memory plan. */ +export async function interruptPlannerJobs( + deps: Pick< + PlannerJobDeps, + "plan" | "exclusive" | "persist" | "publishJobs" | "publishMeta" | "onError" + >, +): Promise { + let interrupted: Job | undefined; + await deps.exclusive(async () => { + let previous = deps.plan.conversationPlanJobs; + interrupted = previous.find(job => job.status === "running"); + if (!interrupted) return; + deps.plan.conversationPlanJobs = Jobs.restore(previous); + try { + await deps.persist(); + } catch (error) { + deps.plan.conversationPlanJobs = previous; + throw error; + } + }); + if (!interrupted) return false; + let report = (error: unknown) => { + try { + if (deps.onError) deps.onError(error); + else console.error("[conversation-plan] could not publish interrupted Planner job:", error); + } catch (reportError) { + console.error("[conversation-plan] Planner job error reporting failed:", reportError); + } + }; + try { + deps.publishJobs(deps.plan.conversationPlanJobs); + } catch (error) { + report(error); + } + if (interrupted.target !== "document") { + try { + deps.publishMeta(interrupted.target); + } catch (error) { + report(error); + } + } + return true; +} diff --git a/apps/server/src/conversation-plan/planner-job-support.ts b/apps/server/src/conversation-plan/planner-job-support.ts new file mode 100644 index 00000000..e85f399c --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-support.ts @@ -0,0 +1,82 @@ +import type { JobIntent } from "./effects"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; +import { proseIntent } from "./prose-job"; + +export function reason(value: unknown, fallback: string): string { + let text = value instanceof Error ? value.message : String(value ?? ""); + return text.trim().slice(0, 500) || fallback; +} + +export function outcome(value: JobOutcome): JobOutcome { + if (value?.status === "done") { + if ( + typeof value.output === "string" && value.output.length > 0 && value.output.length <= 4096 + ) { + try { + JSON.parse(value.output); + return value; + } catch {} + } + return { status: "failed", reason: "Invalid Planner job output." }; + } + if (value?.status === "failed" || value?.status === "skipped") { + return { status: value.status, reason: reason(value.reason, "Planner job failed.") }; + } + return { status: "failed", reason: "Invalid Planner job outcome." }; +} + +function title(plan: PlannerJobDeps["plan"], id: string): string { + return plan.records.get(id)?.definition.questions[0]?.question ?? "a decision"; +} + +export function currentProse(plan: PlannerJobDeps["plan"], job: Job): boolean { + let record = plan.records.get(job.target); + return !!record && proseIntent(record)?.trigger === job.trigger; +} + +export function canonicalIntent(plan: PlannerJobDeps["plan"], intent: JobIntent): JobIntent { + if (intent.kind !== "refine" && intent.kind !== "suggest") return intent; + let event = plan.conversationPlan.events.find(item => item.id === intent.trigger); + if (!event) return intent; + let thread = plan.conversationPlan.threads.find(item => + item.id === event.threadId && item.questionnaireId === intent.target + ); + let messageId = intent.kind === "refine" + && event.type === "card.linked" && event.questionnaireId === intent.target + ? thread?.questionSources[0]?.messageId + : intent.kind === "suggest" && event.type === "option.added" && thread + ? event.source?.messageId + : undefined; + if (!messageId) throw new Error("Planner job event has no matching source message"); + return { ...intent, trigger: messageId }; +} + +/** The activity copy and target for a completed job, when one is useful. */ +export function line( + plan: PlannerJobDeps["plan"], + job: Job, + output: string, +): [string, string, string?] | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch {} + let summary = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + let named = typeof summary.title === "string" && summary.title.trim() + ? summary.title.replace(/\s+/g, " ").trim().slice(0, 500) + : title(plan, job.target).replace(/\s+/g, " ").trim().slice(0, 500); + let added = typeof summary.added === "number" && Number.isSafeInteger(summary.added) + && summary.added >= 0 && summary.added <= 10 + ? summary.added + : 0; + if (job.kind === "heading") return ["Chopin drafted the title and goal", "document"]; + if (job.kind === "refine") return [`Chopin refined ${named}`, job.target, named]; + if (job.kind === "suggest" && added > 0) { + return [`Chopin suggested ${added} options for ${named}`, job.target, named]; + } + if (job.kind === "prose") return [`Chopin wrote up ${named}`, job.target, named]; + return undefined; +} diff --git a/apps/server/src/conversation-plan/planner-job-types.ts b/apps/server/src/conversation-plan/planner-job-types.ts new file mode 100644 index 00000000..38aaca99 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-types.ts @@ -0,0 +1,36 @@ +import type { Job, JobOutcome } from "./jobs"; +import type { Plan } from "../plan/service"; + +export type PlannerJobRunner = ( + job: Job, + prompt: string, + signal?: AbortSignal, +) => Promise; + +export type PlannerJobDeps = { + plan: + & Pick< + Plan, + | "conversationPlanJobs" + | "conversationPlanPendingEffects" + | "conversationPlanEffects" + | "records" + | "chat" + | "conversationPlan" + > + & Partial; + exclusive: (action: () => Promise) => Promise; + persist: () => Promise; + runner: PlannerJobRunner; + prompt: (job: Job) => string | undefined; + publishJobs: (jobs: Job[]) => void; + publishMeta: (questionnaireId: string) => void; + /** Called under exclusive; must commit the whole plan without acquiring the lock again. */ + activity: (text: string, questionnaireId: string, label?: string) => Promise; + /** False when the heading job's document already has prose. */ + headingAllowed?: () => boolean; + /** Wake the durable effects outbox after terminal work makes queue space available. */ + onCapacityAvailable?: () => void; + onError?: (error: unknown) => void; + now?: () => string; +}; diff --git a/apps/server/src/conversation-plan/planner-jobs-completion.test.ts b/apps/server/src/conversation-plan/planner-jobs-completion.test.ts new file mode 100644 index 00000000..35f25707 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-completion.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import type { JobOutcome } from "./jobs"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed job can be retried once, and a completed job cannot", async () => { + let attempts = 0; + let h = harness(async () => + ++attempts === 1 + ? { status: "failed", reason: "Copilot unavailable" } + : { status: "done", output: "{}" } + ); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + status: "failed", + attempts: 1, + reason: "Copilot unavailable", + }); + expect(await h.service.retry("refine:W1:m1")).toBe(true); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "done", attempts: 2 }); + expect(await h.service.retry("refine:W1:m1")).toBe(false); + expect(attempts).toBe(2); + }); + + test("closed cards skip their work without invoking the runner", async () => { + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }); + h.plan.records.get("W1")!.status = "answered"; + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(called).toBe(0); + expect(h.service.jobs()[0]).toMatchObject({ status: "skipped", attempts: 1 }); + expect(h.lines).toEqual([]); + }); + + test("the heading precondition is checked while enqueue holds the document lock", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + headingAllowed: () => false, + }); + await h.service.enqueue({ kind: "heading", target: "document", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("invalid runner output becomes a bounded failure, including malformed JSON", async () => { + let h = harness(async () => ({ status: "done", output: "not JSON" })); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + status: "failed", + reason: "Invalid Planner job output.", + }); + expect(h.lines).toEqual([]); + }); + + test("a valid JSON primitive cannot crash activity formatting", async () => { + let h = harness(async () => ({ status: "done", output: "null" })); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(h.lines).toEqual(["Chopin refined What auth system should we use?"]); + }); + + test("a storage failure at start leaves a pending job and does not spin", async () => { + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }, { failPersist: attempt => attempt === 2 }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("pending"); + expect(h.commits).toHaveLength(1); + expect(h.errors).toHaveLength(1); + expect(called).toBe(0); + h.service.wake(); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(called).toBe(1); + }); + + test("a failed activity commit restores the running job and removes its line", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + failPersist: attempt => attempt === 3, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual(["pending", "running"]); + expect(h.lines).toEqual([]); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:running"]); + expect(h.errors).toHaveLength(1); + }); + + test("stop fences a late runner result and leaves durable running work for recovery", async () => { + let finish: ((outcome: JobOutcome) => void) | undefined; + let h = harness(() => + new Promise(resolve => { + finish = resolve; + }) + ); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + for (let attempt = 0; attempt < 10; attempt++) { + if (finish) break; + await Promise.resolve(); + } + expect(finish).toBeDefined(); + h.service.stop(); + finish!({ status: "done", output: "{}" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits).toHaveLength(2); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:running"]); + expect(h.lines).toEqual([]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-failure.test.ts new file mode 100644 index 00000000..44fa0c2e --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-failure.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import * as Jobs from "./jobs"; +import { AT, harness, interrupt } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("interruption persistence failure leaves running status unpublished", async () => { + let entered = Promise.withResolvers(); + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }, { failPersist: attempt => attempt === 3 }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + await h.service.idle(); + let before = h.plan.conversationPlanJobs; + let published = h.published.length; + await expect(interrupt(h)).rejects.toThrow("storage failure 3"); + expect(h.plan.conversationPlanJobs).toBe(before); + expect(h.plan.conversationPlanJobs[0]?.status).toBe("running"); + expect(h.published).toHaveLength(published); + }); + + test("enqueue after stop rejects so an effect receipt cannot consume unqueued work", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.service.stop(); + await expect(h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" })) + .rejects.toThrow("Planner job service is stopped"); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("stop while enqueue waits for the lock does not acknowledge an uncommitted job", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let h = harness(async () => ({ status: "done", output: "{}" }), { + exclusive: async action => { + entered.resolve(); + await release.promise; + return action(); + }, + }); + let pending = h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + release.resolve(); + await expect(pending).rejects.toThrow("Planner job service is stopped"); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("a keyed enqueue commits a coalesced intent and receipt together", async () => { + let ran: string[] = []; + let h = harness(async job => { + ran.push(job.id); + return { status: "done", output: "{}" }; + }); + h.plan.conversationPlanJobs = Jobs.enqueue([], { + kind: "refine", + target: "W1", + trigger: "m1", + }, AT); + let intent = { kind: "suggest" as const, target: "W1", trigger: "m2" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:e2", + kind: "job", + threadId: "t1", + intent, + }]; + await h.service.enqueue(intent, "job:suggest:W1:e2"); + await h.service.idle(); + expect(h.commits[0]).toMatchObject({ + jobs: [{ id: "refine:W1:m1", status: "pending" }], + pending: [], + receipts: ["job:suggest:W1:e2"], + }); + expect(ran).toEqual(["refine:W1:m1"]); + let commits = h.commits.length; + await h.service.enqueue(intent, "job:suggest:W1:e2"); + await h.service.idle(); + expect(h.commits).toHaveLength(commits); + expect(h.service.jobs()).toHaveLength(1); + expect(ran).toEqual(["refine:W1:m1"]); + }); + + test("a failed keyed commit restores the job queue, pending effect, and receipt", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + failPersist: attempt => attempt === 1, + }); + let intent = { kind: "refine" as const, target: "W1", trigger: "m1" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:e1", + kind: "job", + threadId: "t1", + intent, + }]; + let jobs = h.plan.conversationPlanJobs; + let pending = h.plan.conversationPlanPendingEffects; + let receipts = h.plan.conversationPlanEffects; + await expect(h.service.enqueue(intent, "job:refine:W1:e1")) + .rejects.toThrow("storage failure 1"); + expect(h.plan.conversationPlanJobs).toBe(jobs); + expect(h.plan.conversationPlanPendingEffects).toBe(pending); + expect(h.plan.conversationPlanEffects).toBe(receipts); + expect(h.published).toEqual([]); + await h.service.enqueue(intent, "job:refine:W1:e1"); + await h.service.idle(); + expect(h.commits[0]?.receipts).toEqual(["job:refine:W1:e1"]); + expect(h.service.jobs()[0]?.status).toBe("done"); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts b/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts new file mode 100644 index 00000000..43c0f8bd --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { type Effect, runEffects } from "./effects"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed prose enqueue stays in the closed card outbox for a later wake", async () => { + let ran = 0; + let h = harness(async () => { + ran++; + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }, { failPersist: attempt => attempt === 1 }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + let intent = { kind: "prose" as const, target: "W1", trigger: "decided:W1:1" }; + let effect: Effect = { key: "job:prose:W1:1", kind: "job", threadId: "t1", intent }; + h.plan.conversationPlanPendingEffects = [effect]; + let reported: unknown[] = []; + let deps = { + applied: (key: string) => h.plan.conversationPlanEffects.includes(key), + markApplied: async () => {}, + target: () => ({ kind: "closed" as const }), + proseReady: (thread: string, id: string, trigger: string) => + thread === "t1" && id === "W1" && trigger === intent.trigger, + insertCard: async () => "W1", + link: async () => {}, + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: h.service.enqueue, + report: (error: unknown) => reported.push(error), + }; + expect(await runEffects(deps, [effect])).toBe(0); + expect(h.plan.conversationPlanPendingEffects).toEqual([effect]); + expect(h.plan.conversationPlanEffects).toEqual([]); + expect(h.service.jobs()).toEqual([]); + expect(reported).toHaveLength(1); + expect(await runEffects(deps, [effect])).toBe(1); + await h.service.idle(); + expect(h.plan.conversationPlanPendingEffects).toEqual([]); + expect(h.plan.conversationPlanEffects).toEqual([effect.key]); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(ran).toBe(1); + }); + + test("a keyed enqueue rejects an unknown or mismatched pending effect", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + let intent = { kind: "refine" as const, target: "W1", trigger: "m1" }; + await expect(h.service.enqueue(intent, "missing")).rejects.toThrow("pending job effect"); + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:e1", + kind: "job", + intent: { + kind: "suggest", + target: "W1", + trigger: "m1", + }, + }]; + await expect(h.service.enqueue(intent, "job:suggest:W1:e1")) + .rejects.toThrow("pending job effect"); + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:e2", + kind: "job", + intent: { + kind: "refine", + target: "W1", + trigger: "different-message", + }, + }]; + await expect(h.service.enqueue(intent, "job:refine:W1:e2")) + .rejects.toThrow("pending job effect"); + expect(h.commits).toEqual([]); + }); + + test("legacy event triggers become source message IDs in durable job records", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.plan.conversationPlan.threads = [{ + id: "t1", + questionnaireId: "W1", + questionSources: [{ messageId: "question-message" }], + }]; + h.plan.conversationPlan.events = [{ + id: "card-event", + type: "card.linked", + threadId: "t1", + questionnaireId: "W1", + }, { + id: "option-event", + type: "option.added", + threadId: "t1", + source: { messageId: "option-message" }, + }]; + let refine = { kind: "refine" as const, target: "W1", trigger: "card-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:card-event", + kind: "job", + threadId: "t1", + intent: refine, + }]; + await h.service.enqueue(refine, "job:refine:W1:card-event"); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + id: "refine:W1:question-message", + trigger: "question-message", + status: "done", + }); + let suggest = { kind: "suggest" as const, target: "W1", trigger: "option-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:option-event", + kind: "job", + threadId: "t1", + intent: suggest, + }]; + await h.service.enqueue(suggest, "job:suggest:W1:option-event"); + await h.service.idle(); + expect(h.service.jobs()[1]).toMatchObject({ + id: "suggest:W1:option-message", + trigger: "option-message", + status: "done", + }); + expect(h.plan.conversationPlanEffects).toEqual([ + "job:refine:W1:card-event", + "job:suggest:W1:option-event", + ]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts new file mode 100644 index 00000000..690eddc0 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { memoryJobs, message } from "./planner-jobs-memory.test-fixtures"; +import { until } from "./service.test-fixtures"; + +test("a failed actual enqueue keeps its outbox effect without a receipt and retries after reopen", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + h.rejectCommits(input => + !!(input.sidecar as { conversationPlanEffects?: string[] }) + .conversationPlanEffects?.includes("job:heading:document") + ); + let { processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.errors.length > 0); + await jobs.idle(); + let held = await h.saved(); + expect(held.conversationPlanJobs).toBeUndefined(); + expect(held.conversationPlanEffects).toBeUndefined(); + expect(held.conversationPlanPendingEffects).toMatchObject([{ key: "job:heading:document" }]); + expect(h.plan.conversationPlanJobs).toEqual([]); + expect(h.publications).toEqual([]); + expect(runs).toBe(0); + h.rejectCommits(() => false); + await h.reopen(); + ({ processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "done"); + await jobs.idle(); + let settled = await h.saved(); + expect(settled.conversationPlanEffects).toEqual(["job:heading:document"]); + expect(settled.conversationPlanPendingEffects).toBeUndefined(); + expect(runs).toBe(1); + } finally { + h.rejectCommits(() => false); + await h.close(); + } +}); + +test("stopping a real held runner retains running durability until reopen records interruption", async () => { + let h = await memoryJobs(); + let entered = Promise.withResolvers(); + let runs = 0; + try { + let { processor, jobs } = h.start(async (_job, _prompt, signal) => { + runs++; + entered.resolve(); + await new Promise(resolve => + signal!.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await entered.promise; + jobs.stop(); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "running", + attempts: 0, + }]); + expect(h.publications.map(jobs => jobs[0]?.status)).toEqual(["pending", "running"]); + await h.reopen(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "failed", + reason: "interrupted", + attempts: 0, + }]); + ({ jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + jobs.wake(); + await jobs.idle(); + expect(runs).toBe(1); + expect(h.plan.chat.entries).toEqual([message]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts b/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts new file mode 100644 index 00000000..b4a50eb1 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts @@ -0,0 +1,142 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { createProcessor } from "./service"; +import { createPlannerJobs, type PlannerJobRunner } from "./planner-jobs"; +import { PROMPT_FOR } from "./job-prompts"; + +function allowCommit(): boolean { + return false; +} + +async function unsupported(): Promise { + throw new Error("unexpected card effect"); +} + +export async function memoryJobs() { + let context = await openPlan(); + let plan = context.plan; + let errors: unknown[] = []; + let publications: ConversationPlan.Job[][] = []; + let jobs: ReturnType | undefined; + let processor: ReturnType | undefined; + let original = context.storage.collaboration.commit; + let reject: (input: Parameters[0]) => boolean = allowCommit; + context.backend.fatal = error => errors.push(error); + plan.persistence.fatal = context.backend.fatal; + context.storage.collaboration.commit = async input => { + if (reject(input)) throw new Error("memory commit rejected"); + return original(input); + }; + async function saved() { + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + return (loaded.sidecar ?? loaded.snapshot!.sidecar) as unknown as { + transcript: typeof plan.chat.entries; + conversationPlan: ConversationPlan.State; + conversationPlanJobs?: ConversationPlan.Job[]; + conversationPlanEffects?: string[]; + conversationPlanPendingEffects?: unknown[]; + }; + } + function start(runner: PlannerJobRunner) { + let captured = plan; + let committedJobs = structuredClone(captured.conversationPlanJobs); + let committedState = structuredClone(captured.conversationPlan); + let exclusive = (action: () => Promise) => Plan.exclusive(captured, action); + let persist = async () => { + await Plan.persistExclusive(captured); + let sidecar = await saved(); + committedJobs = sidecar.conversationPlanJobs ?? []; + committedState = sidecar.conversationPlan; + }; + jobs = createPlannerJobs({ + plan: captured, + exclusive, + persist, + runner, + prompt: job => PROMPT_FOR[job.kind]?.(captured, job), + publishJobs: current => { + expect(current).toEqual(committedJobs); + publications.push(structuredClone(current)); + }, + publishMeta: () => { + throw new Error("unexpected card job"); + }, + activity: async text => { + let before = captured.chat.entries; + captured.chat.entries = [...before, { + id: crypto.randomUUID(), + author: { kind: "system" }, + text, + ts: 2, + }]; + try { + await persist(); + } catch (error) { + captured.chat.entries = before; + throw error; + } + }, + onCapacityAvailable: () => processor?.wake(), + onError: error => errors.push(error), + }); + let coordinator = jobs; + processor = createProcessor({ + plan: captured, + exclusive, + persist, + active: () => true, + publish: state => expect(state).toEqual(committedState), + interpret: input => + interpretMessage({ + ...input, + ask: async request => mockResult(request.questions, { enough_purpose: 0.9 }), + }), + effects: { + target: () => ({ kind: "unlinked" }), + insertCard: unsupported, + link: unsupported, + addOption: unsupported, + suggest: unsupported, + prompt: unsupported, + enqueueJob: coordinator.enqueue, + report: error => errors.push(error), + }, + onError: error => errors.push(error), + }); + return { jobs: coordinator, processor }; + } + async function close() { + processor?.stop(); + jobs?.stop(); + await jobs?.idle(); + await Plan.close(plan); + } + return { + get plan() { + return plan; + }, + start, + saved, + publications, + errors, + rejectCommits(check: typeof reject) { + reject = check; + }, + async reopen() { + await close(); + plan = await Plan.open(context.channel.id, context.backend, context.server); + }, + close, + }; +} + +export let message = { + id: "purpose", + author: { kind: "member" as const, handle: "ana" }, + text: "We need to figure out our authentication approach.", + ts: 1, +}; diff --git a/apps/server/src/conversation-plan/planner-jobs-memory.test.ts b/apps/server/src/conversation-plan/planner-jobs-memory.test.ts new file mode 100644 index 00000000..2aed65a8 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { memoryJobs, message } from "./planner-jobs-memory.test-fixtures"; +import { until } from "./service.test-fixtures"; + +test("real processor commits a heading receipt and running job before Planner execution", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + let { processor, jobs } = h.start(async (job, prompt) => { + runs++; + let saved = await h.saved(); + expect(saved.conversationPlanEffects).toEqual(["job:heading:document"]); + expect(saved.conversationPlanPendingEffects).toBeUndefined(); + expect(saved.conversationPlanJobs).toMatchObject([{ id: job.id, status: "running" }]); + expect(saved.transcript).toEqual([message]); + expect(prompt).toContain("[Background job: heading]"); + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "done"); + await jobs.idle(); + let completed = await h.saved(); + expect(completed.conversationPlanJobs).toMatchObject([{ status: "done", attempts: 1 }]); + expect(completed.transcript.map(entry => entry.text)).toEqual([ + message.text, + "Chopin drafted the title and goal", + ]); + expect(h.publications.map(jobs => jobs[0]?.status)).toEqual(["pending", "running", "done"]); + await h.reopen(); + ({ processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + processor.afterMessage(); + jobs.wake(); + await jobs.idle(); + expect(h.plan.conversationPlanJobs[0]?.status).toBe("done"); + expect(runs).toBe(1); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("a real persisted Planner failure reopens as failed and only explicit retry runs it", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + let { processor, jobs } = h.start(async () => { + runs++; + throw new Error("runner unavailable"); + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "failed"); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "failed", + attempts: 1, + }]); + await h.reopen(); + ({ jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + jobs.wake(); + await jobs.idle(); + expect(runs).toBe(1); + expect(await jobs.retry(h.plan.conversationPlanJobs[0]!.id)).toBe(true); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ status: "done", attempts: 2 }]); + expect(runs).toBe(2); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts new file mode 100644 index 00000000..f9110e56 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { type Effect, runEffects } from "./effects"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed option receipt blocks its same-thread job until the option replay succeeds", async () => { + let ran = 0; + let h = harness(async () => { + ran++; + return { status: "done", output: "{}" }; + }); + let receipts = new Set(); + let marked = 0; + let added: string[] = []; + let reported: unknown[] = []; + let effects: Effect[] = [ + { + key: "job:refine:W1:e1", + kind: "job", + threadId: "t1", + intent: { kind: "refine", target: "W1", trigger: "m1" }, + }, + { + key: "option:o1", + kind: "add-option", + threadId: "t1", + optionId: "o1", + label: "GitHub Apps", + trigger: "m2", + }, + ]; + h.plan.conversationPlanPendingEffects = [effects[0]!]; + let deps = { + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (++marked === 1) throw new Error("receipt lost"); + receipts.add(key); + }, + target: () => ({ kind: "open" as const, id: "W1" }), + insertCard: async () => "W1", + link: async () => {}, + addOption: async (_id: string, input: { optionId: string }) => { + if (!added.includes(input.optionId)) added.push(input.optionId); + }, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: h.service.enqueue, + report: (error: unknown) => reported.push(error), + }; + expect(await runEffects(deps, effects)).toBe(0); + expect(added).toEqual(["o1"]); + await h.service.idle(); + expect(await runEffects(deps, effects)).toBe(2); + await h.service.idle(); + expect(added).toEqual(["o1"]); + expect(ran).toBe(1); + expect(h.service.jobs()).toHaveLength(1); + expect(reported).toHaveLength(1); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts b/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts new file mode 100644 index 00000000..62d1960e --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import type { JobOutcome } from "./jobs"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a legacy event without a matching source keeps its effect pending", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.plan.conversationPlan.events = [{ + id: "card-event", + type: "card.linked", + threadId: "t1", + questionnaireId: "W1", + }]; + let intent = { kind: "refine" as const, target: "W1", trigger: "card-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:card-event", + kind: "job", + threadId: "t1", + intent, + }]; + await expect(h.service.enqueue(intent, "job:refine:W1:card-event")) + .rejects.toThrow("source message"); + expect(h.service.jobs()).toEqual([]); + expect(h.plan.conversationPlanPendingEffects).toHaveLength(1); + expect(h.plan.conversationPlanEffects).toEqual([]); + }); + + test("stop during the running commit fences the runner and its publication", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }, { + waitPersist: async attempt => { + if (attempt !== 2) return; + entered.resolve(); + await release.promise; + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + release.resolve(); + await h.service.idle(); + expect(called).toBe(0); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual(["pending", "running"]); + expect(h.published).toEqual([["refine:W1:m1:pending"]]); + }); + + test("a wake during active work drains newly enqueued work before idle", async () => { + let finish: ((outcome: JobOutcome) => void) | undefined; + let seen: string[] = []; + let h = harness(job => { + seen.push(job.id); + return seen.length === 1 + ? new Promise(resolve => { + finish = resolve; + }) + : Promise.resolve({ status: "done", output: "{}" }); + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + for (let attempt = 0; attempt < 10; attempt++) { + if (finish) break; + await Promise.resolve(); + } + expect(finish).toBeDefined(); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + finish!({ status: "done", output: "{}" }); + await h.service.idle(); + expect(seen).toEqual(["refine:W1:m1", "suggest:W1:m2"]); + expect(h.service.jobs().map(job => job.status)).toEqual(["done", "done"]); + }); + + test("a durable completion wakes the effects outbox to use freed capacity", async () => { + let observed: string[] = []; + let h = harness(async () => ({ status: "done", output: "{}" }), { + onCapacityAvailable: () => { + observed.push(h.commits.at(-1)?.jobs[0]?.status ?? "missing"); + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(observed).toEqual(["done"]); + }); + + test("a durable skip wakes the effects outbox to reclaim terminal capacity", async () => { + let observed: string[] = []; + let h = harness(async () => ({ status: "skipped", reason: "AGENT=off" }), { + onCapacityAvailable: () => { + observed.push(h.commits.at(-1)?.jobs[0]?.status ?? "missing"); + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(observed).toEqual(["skipped"]); + }); + + test("thrown runner errors become bounded, retryable failure reasons", async () => { + let h = harness(async () => { + throw new Error("x".repeat(1000)); + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "failed", attempts: 1 }); + expect(h.service.jobs()[0]?.reason).toHaveLength(500); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-retry.test.ts b/apps/server/src/conversation-plan/planner-jobs-retry.test.ts new file mode 100644 index 00000000..209219be --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-retry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { createPlannerJobs } from "./planner-jobs"; +import { AT, harness, interrupt } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("stop aborts a held scripted runner while retaining its durable running status", async () => { + let entered = Promise.withResolvers(); + let supplied = false; + let aborted = false; + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + if (!signal) throw new Error("runner signal is missing"); + supplied = true; + await new Promise(resolve => + signal.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + aborted = signal.aborted; + return { status: "skipped", reason: "stopped" }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + await h.service.idle(); + expect(supplied).toBe(true); + expect(aborted).toBe(true); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits).toHaveLength(2); + }); + + test("in-memory restart durably interrupts running work and drains later pending work", async () => { + let entered = Promise.withResolvers(); + let called: string[] = []; + let h = harness(async (job, _prompt, signal) => { + called.push(job.id); + if (called.length > 1) return { status: "done", output: "{}" }; + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + h.service.stop(); + await h.service.idle(); + expect(h.service.jobs().map(job => job.status)).toEqual(["running", "pending"]); + expect(await interrupt(h)).toBe(true); + expect(h.commits.at(-1)?.jobs).toMatchObject([ + { status: "failed", reason: "interrupted" }, + { status: "pending" }, + ]); + expect(h.published.at(-1)).toEqual([ + "refine:W1:m1:failed", + "suggest:W1:m2:pending", + ]); + expect(h.publishedAfter.at(-1)).toBe(h.commits.length); + let restarted = createPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + runner: async job => { + called.push(job.id); + return { status: "done", output: "{}" }; + }, + prompt: () => "prompt", + publishJobs: () => {}, + publishMeta: () => {}, + activity: h.persist, + now: () => AT, + }); + restarted.wake(); + await restarted.idle(); + expect(called).toEqual(["refine:W1:m1", "suggest:W1:m2"]); + expect(h.plan.conversationPlanJobs.map(job => job.status)).toEqual(["failed", "done"]); + }); + + test("an interrupted old prose generation does not replay after restart", async () => { + let entered = Promise.withResolvers(); + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await entered.promise; + let previous = h.plan.records.get("W1")!; + h.plan.records.set("W1", { ...previous, history: [{}] } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:2" }); + h.service.stop(); + await h.service.idle(); + expect(await interrupt(h)).toBe(true); + let ran: string[] = []; + let restarted = createPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + runner: async job => { + ran.push(job.trigger); + return { status: "done", output: "{}" }; + }, + prompt: () => "prompt", + publishJobs: () => {}, + publishMeta: () => {}, + activity: h.persist, + now: () => AT, + }); + restarted.wake(); + await restarted.idle(); + expect(h.service.jobs().map(job => [job.status, job.reason])).toEqual([ + ["failed", "interrupted"], + ["done", undefined], + ]); + expect(ran).toEqual(["decided:W1:2"]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts b/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts new file mode 100644 index 00000000..666abbd4 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts @@ -0,0 +1,88 @@ +import type { Effect } from "./effects"; +import { createPlannerJobs, interruptPlannerJobs } from "./planner-jobs"; +import type { Job, JobOutcome } from "./jobs"; + +export const AT = "2026-09-25T10:00:00.000Z"; + +export function harness( + runner: (job: Job, prompt: string, signal?: AbortSignal) => Promise, + options: { + failPersist?: (attempt: number) => boolean; + waitPersist?: (attempt: number) => Promise; + headingAllowed?: () => boolean; + onCapacityAvailable?: () => void; + exclusive?: (action: () => Promise) => Promise; + } = {}, +) { + let commits: Array<{ jobs: Job[]; lines: string[]; pending: Effect[]; receipts: string[] }> = []; + let published: string[][] = []; + let publishedAfter: number[] = []; + let meta: string[] = []; + let lines: string[] = []; + let errors: unknown[] = []; + let persistAttempts = 0; + let plan = { + conversationPlanJobs: [] as Job[], + conversationPlanPendingEffects: [] as Effect[], + conversationPlanEffects: [] as string[], + records: new Map([["W1", { + status: "open", + definition: { questions: [{ question: "What auth system should we use?", options: [] }] }, + }]]), + chat: { entries: [] }, + conversationPlan: { threads: [] as unknown[], events: [] as unknown[] }, + }; + let persist = async () => { + persistAttempts++; + await options.waitPersist?.(persistAttempts); + if (options.failPersist?.(persistAttempts)) { + throw new Error(`storage failure ${persistAttempts}`); + } + commits.push({ + jobs: structuredClone(plan.conversationPlanJobs), + lines: [...lines], + pending: structuredClone(plan.conversationPlanPendingEffects), + receipts: [...plan.conversationPlanEffects], + }); + }; + let service = createPlannerJobs({ + plan: plan as never, + exclusive: options.exclusive ?? (async action => action()), + persist, + runner, + prompt: () => "prompt", + publishJobs: jobs => { + published.push(jobs.map(job => `${job.id}:${job.status}`)); + publishedAfter.push(commits.length); + }, + publishMeta: id => meta.push(id), + activity: async text => { + lines.push(text); + try { + await persist(); + } catch (error) { + lines.pop(); + throw error; + } + }, + onError: error => errors.push(error), + headingAllowed: options.headingAllowed, + onCapacityAvailable: options.onCapacityAvailable, + now: () => AT, + }); + return { plan, service, persist, commits, published, publishedAfter, meta, lines, errors }; +} + +export function interrupt(h: ReturnType): Promise { + return interruptPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + publishJobs: jobs => { + h.published.push(jobs.map(job => `${job.id}:${job.status}`)); + h.publishedAfter.push(h.commits.length); + }, + publishMeta: id => h.meta.push(id), + onError: error => h.errors.push(error), + }); +} diff --git a/apps/server/src/conversation-plan/planner-jobs.test.ts b/apps/server/src/conversation-plan/planner-jobs.test.ts new file mode 100644 index 00000000..3a6d621a --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import * as Jobs from "./jobs"; +import type { Job } from "./jobs"; +import { AT, harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("posts one durable prose activity line after a completed write", async () => { + let h = harness(async () => ({ + status: "done", + output: JSON.stringify({ title: "Which authentication approach?", mode: "insert" }), + })); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Which authentication approach?", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await h.service.idle(); + expect(h.lines).toEqual(["Chopin wrote up Which authentication approach?"]); + expect(h.commits.at(-1)?.lines).toEqual(h.lines); + }); + + test("a queued old prose generation skips before prompting or invoking the runner", async () => { + let ran: string[] = []; + let h = harness(async job => { + ran.push(job.trigger); + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "cy", + decidedAt: 1, + history: [{ choices: [], owner: "ana", at: 1 }], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + h.plan.conversationPlanJobs = Jobs.enqueue( + Jobs.enqueue([], { kind: "prose", target: "W1", trigger: "decided:W1:1" }, AT), + { kind: "prose", target: "W1", trigger: "decided:W1:2" }, + AT, + ); + h.service.wake(); + await h.service.idle(); + expect(h.plan.conversationPlanJobs.map(job => job.status)).toEqual(["skipped", "done"]); + expect(ran).toEqual(["decided:W1:2"]); + }); + + test("a prose turn superseded by a new decision does not announce an old success", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let h = harness(async () => { + entered.resolve(); + await release.promise; + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await entered.promise; + let previous = h.plan.records.get("W1")!; + h.plan.records.set("W1", { ...previous, history: [{}] } as never); + release.resolve(); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "skipped" }); + expect(h.lines).toEqual([]); + }); + + test("persists running before the runner and completes with one durable activity line", async () => { + let seen: Array<{ job: Job; committed: string }> = []; + let h = harness(async job => { + seen.push({ job, committed: h.commits.at(-1)!.jobs[0]!.status }); + return { + status: "done", + output: JSON.stringify({ title: "What auth system should we use?", added: 1 }), + }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(seen).toMatchObject([{ + job: { id: "refine:W1:m1", status: "running" }, + committed: "running", + }]); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual([ + "pending", + "running", + "done", + ]); + expect(h.commits.at(-1)?.lines).toEqual([ + "Chopin refined What auth system should we use?", + ]); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:done"]); + expect(h.publishedAfter).toEqual([1, 2, 3]); + expect(h.meta).toEqual(["W1", "W1", "W1"]); + expect(h.errors).toEqual([]); + }); + + test("suggestion activity appears only when an option was added", async () => { + let added = [0, 2]; + let h = harness(async () => ({ + status: "done", + output: JSON.stringify({ title: "Authentication?", added: added.shift() }), + })); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m1" }); + await h.service.idle(); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + await h.service.idle(); + expect(h.lines).toEqual(["Chopin suggested 2 options for Authentication?"]); + expect(h.commits).toHaveLength(6); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs.ts b/apps/server/src/conversation-plan/planner-jobs.ts new file mode 100644 index 00000000..949fefa5 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.ts @@ -0,0 +1,231 @@ +/** Runs one document's durable Planner jobs in order. */ + +import * as Jobs from "./jobs"; +import { type JobIntent, MAX_EFFECTS } from "./effects"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; +import { canonicalIntent, currentProse, line, outcome, reason } from "./planner-job-support"; + +export { interruptPlannerJobs } from "./planner-job-interruption"; +export { line } from "./planner-job-support"; +export type { PlannerJobDeps, PlannerJobRunner } from "./planner-job-types"; + +export function createPlannerJobs(deps: PlannerJobDeps) { + let { plan } = deps; + let now = deps.now ?? (() => new Date().toISOString()); + let stopped = false; + let controller = new AbortController(); + let draining: Promise | undefined; + let wakeRequested = false; + + function report(error: unknown): void { + try { + if (deps.onError) deps.onError(error); + else console.error("[conversation-plan] Planner job failed:", error); + } catch (reportError) { + console.error("[conversation-plan] Planner job error reporting failed:", reportError); + } + } + + async function change( + next: (jobs: Job[]) => Job[], + commit: () => Promise = deps.persist, + ): Promise { + return deps.exclusive(async () => { + if (stopped) return undefined; + let previous = plan.conversationPlanJobs; + let updated = next(previous); + if (updated === previous) return undefined; + plan.conversationPlanJobs = updated; + try { + await commit(); + } catch (error) { + plan.conversationPlanJobs = previous; + throw error; + } + return updated; + }); + } + + async function enqueueWithReceipt(intent: JobIntent, key: string): Promise { + return deps.exclusive(async () => { + let receipts = plan.conversationPlanEffects; + if (receipts.includes(key)) return undefined; + if (stopped) throw new Error("Planner job service is stopped"); + let pending = plan.conversationPlanPendingEffects; + let effect = pending.find(item => item.key === key); + if ( + effect?.kind !== "job" || effect.intent.kind !== intent.kind + || effect.intent.target !== intent.target + || effect.intent.trigger !== intent.trigger + ) throw new Error("Planner job receipt has no matching pending job effect"); + + let previous = plan.conversationPlanJobs; + let canonical = canonicalIntent(plan, intent); + let updated = intent.kind === "heading" + && (previous.some(job => job.kind === "heading") || deps.headingAllowed?.() === false) + ? previous + : Jobs.enqueue(previous, canonical, now()); + plan.conversationPlanJobs = updated; + plan.conversationPlanPendingEffects = pending.filter(item => item.key !== key); + plan.conversationPlanEffects = [...receipts, key].slice(-MAX_EFFECTS); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanJobs = previous; + plan.conversationPlanPendingEffects = pending; + plan.conversationPlanEffects = receipts; + throw error; + } + return updated === previous + ? undefined + : updated.find(job => job.id === Jobs.jobId(canonical)); + }); + } + + function announce(job: Job | undefined): void { + if (stopped) return; + try { + deps.publishJobs(plan.conversationPlanJobs); + } catch (error) { + report(error); + } + if (job && job.target !== "document") { + try { + deps.publishMeta(job.target); + } catch (error) { + report(error); + } + } + } + + async function run(job: Job): Promise { + if (job.kind === "heading" && deps.headingAllowed?.() === false) { + return { status: "skipped", reason: "The document already has prose." }; + } + if (job.kind === "refine" || job.kind === "suggest") { + let status = plan.records.get(job.target)?.status; + if (status !== "open" && status !== "reopened") { + return { status: "skipped", reason: "The decision card is no longer open." }; + } + } + if (job.kind === "prose") { + if (!currentProse(plan, job)) { + return { status: "skipped", reason: "The saved decision has changed." }; + } + } + try { + let prompt = deps.prompt(job); + if (prompt === undefined) { + return { status: "failed", reason: `no prompt for ${job.kind} jobs` }; + } + let result = outcome(await deps.runner(job, prompt, controller.signal)); + return job.kind === "prose" && !currentProse(plan, job) + ? { status: "skipped", reason: "The saved decision has changed." } + : result; + } catch (error) { + return { status: "failed", reason: reason(error, "The Planner could not be reached.") }; + } + } + + async function drain(): Promise { + for (;;) { + if (stopped) return; + let pending = Jobs.next(plan.conversationPlanJobs); + if (!pending) return; + let started = await change(jobs => Jobs.start(jobs, pending.id, now())); + if (stopped) return; + if (!started) continue; + let running = started.find(job => job.id === pending.id); + if (!running) throw new Error("started Planner job is missing"); + announce(running); + let result = await run(running); + if (stopped) return; + let finalResult = result; + let activity = result.status === "done" ? line(plan, running, result.output) : undefined; + let settled = await change( + jobs => { + if (running.kind === "prose" && !currentProse(plan, running)) { + finalResult = { status: "skipped", reason: "The saved decision has changed." }; + activity = undefined; + } + return Jobs.settle(jobs, running.id, finalResult, now()); + }, + () => + activity + ? deps.activity(activity[0], activity[1], activity[2]) + : deps.persist(), + ); + if (!settled) continue; + announce(settled.find(job => job.id === running.id)); + if (!stopped && (finalResult.status === "done" || finalResult.status === "skipped")) { + try { + deps.onCapacityAvailable?.(); + } catch (error) { + report(error); + } + } + } + } + + function wake(): void { + if (stopped) return; + if (draining) { + wakeRequested = true; + return; + } + let failed = false; + draining = drain().catch(error => { + failed = true; + report(error); + }).finally(() => { + draining = undefined; + let requested = wakeRequested; + wakeRequested = false; + if (!failed && requested && !stopped && Jobs.next(plan.conversationPlanJobs)) wake(); + }); + } + + return { + async enqueue(intent: JobIntent, effectKey?: string): Promise { + if (effectKey !== undefined) { + let changed = await enqueueWithReceipt(intent, effectKey); + if (changed) announce(changed); + wake(); + return; + } + if (stopped) throw new Error("Planner job service is stopped"); + let changed = await change(jobs => { + if ( + intent.kind === "heading" + && (jobs.some(job => job.kind === "heading") || deps.headingAllowed?.() === false) + ) return jobs; + return Jobs.enqueue(jobs, intent, now()); + }); + if (stopped && !changed) throw new Error("Planner job service is stopped"); + if (changed) announce(changed.find(job => job.id === Jobs.jobId(intent))); + wake(); + }, + async retry(jobId: string): Promise { + let changed = await change(jobs => Jobs.retry(jobs, jobId, now())); + if (!changed) return false; + announce(changed.find(job => job.id === jobId)); + wake(); + return true; + }, + wake, + async idle(): Promise { + let task = draining; + while (task) { + await task; + task = draining; + } + }, + stop(): void { + stopped = true; + controller.abort(); + wakeRequested = false; + }, + jobs: () => plan.conversationPlanJobs, + }; +} diff --git a/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts b/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts new file mode 100644 index 00000000..f84f481e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevAnswer } from "./jev"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; + +let text = + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent’s repository credentials, we could pass each user’s GitHub token or use a GitHub App installation token. Those are two different calls."; +let current: Chat.Entry = { + id: "D19-m2", + text, + author: { kind: "member", handle: "Omar" }, + ts: 1_001, +}; +let threadId = "human-sign-in"; + +function choice(value: string): JevAnswer { + return { + type: "choice", + choice: value, + confidence: 0.95, + probabilities: { [value]: 0.95, none: 0.05 }, + }; +} + +function openThread(): ConversationPlan.State { + let opening: Chat.Entry = { + id: "m1-human", + text: "How should people sign in?", + author: { kind: "member", handle: "Leah" }, + ts: 1_000, + }; + let state = applyInference(initialState(), { + id: "open-human-sign-in", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: opening.ts, + source: { + messageId: opening.id, + author: opening.author as ConversationPlan.SourceAuthor, + quote: opening.text, + start: 0, + end: opening.text.length, + role: "question", + }, + question: opening.text, + }, opening); + let agentQuestion: Chat.Entry = { + id: "m1-agent", + text: "How should the hosted agent get repository credentials?", + author: { kind: "member", handle: "Leah" }, + ts: 1_000, + }; + return applyInference(state, { + id: "open-agent-credentials", + type: "thread.opened", + threadId: "agent-credentials", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: agentQuestion.ts, + source: { + messageId: agentQuestion.id, + author: agentQuestion.author as ConversationPlan.SourceAuthor, + quote: agentQuestion.text, + start: 0, + end: agentQuestion.text.length, + role: "question", + }, + question: agentQuestion.text, + }, agentQuestion); +} + +test("policy accepts the fourth D19 option candidate instead of truncating at three", () => { + let quotes = extractQuotes(text); + let first = { + new_question: { type: "noul" as const, noul: 0.05 }, + new_option: { type: "noul" as const, noul: 0.95 }, + explicit_resolution: { type: "noul" as const, noul: 0.05 }, + act: choice("proposal"), + thread_target: choice(threadId), + ...Object.fromEntries(quotes.map((_, index) => [ + `c${index}_owned_unretracted`, + { type: "noul" as const, noul: 0.95 }, + ])), + }; + let candidates = quotes.map((quote, index) => ({ + ...quote, + answers: { + role: choice("option"), + thread: choice(index < 2 ? threadId : "agent-credentials"), + option: choice("new"), + chosen_option: choice("new"), + new_option: { type: "noul" as const, noul: 0.95 }, + planning_substance: { type: "noul" as const, noul: 0.95 }, + duplicate: { type: "noul" as const, noul: 0.05 }, + support: { type: "noul" as const, noul: 0.05 }, + }, + })); + let planned = planEvents({ + channelId: "channel", + message: current, + state: openThread(), + first, + candidates, + }); + + expect( + planned.events.flatMap(event => + event.type === "option.added" + ? [{ + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + }] + : [] + ), + ).toEqual(quotes); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts b/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts new file mode 100644 index 00000000..6b66fdc6 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { + ordinaryFrame, + ordinaryInput, + withdrawCurrent, +} from "./policy-candidate-ordinary.test-fixtures"; +import { runAgreement } from "./policy-candidate-agreement"; + +test.each([0.7, 0.699])( + "agreement threshold %s returns progression even without a proposal", + probability => { + let input = ordinaryInput("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: probability }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("none"); + let result = runAgreement(context, entry, role); + expect(result).toBeDefined(); + if (probability === 0.7) { + expect(result?.proposed).toMatchObject({ type: "settle.agreed", optionId: "alpha" }); + expect(context.selectedTarget).toBe("provider"); + expect(entry.outcome.targetId).toBe("provider"); + } else { + expect(result?.proposed).toBeUndefined(); + expect(context.selectedTarget).toBeUndefined(); + } + expect(context.events).toHaveLength(0); + }, +); +test("agreement recomputes effective pending from current working", () => { + let input = ordinaryInput("none"); + let { context, entry, role } = ordinaryFrame(input); + context.working = withdrawCurrent(input); + expect(role.pending).toBeDefined(); + expect(runAgreement(context, entry, role)).toEqual({ proposed: undefined }); + expect(context.selectedTarget).toBeUndefined(); +}); +test("agreement retains the raw named option despite low classifier confidence", () => { + let input = ordinaryInput("none"); + input.candidates[0]!.answers.option = { + type: "choice", + choice: "beta", + confidence: 0.4, + probabilities: { beta: 0.4, alpha: 0.35, none: 0.25 }, + }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.option).toBeUndefined(); + expect(runAgreement(context, entry, role)).toEqual({ proposed: undefined }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-agreement.ts b/apps/server/src/conversation-plan/policy-candidate-agreement.ts new file mode 100644 index 00000000..f7aaab05 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-agreement.ts @@ -0,0 +1,47 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; +import { effectivePending } from "./preference"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runAgreement( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { message } = context; + let { candidate, outcome } = entry; + let { thread } = frame; + let selectedTarget = context.selectedTarget; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + try { + let pending = thread && effectivePending(thread, working.events); + let namedOption = candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : undefined; + if ( + thread && pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!namedOption || namedOption === "none" || namedOption === pending.optionId) + && noul(candidate.answers, "agrees_with_settle") >= 0.7 + ) { + proposed = { + ...base(thread.id, "settle.agreed"), + type: "settle.agreed", + source: source("support"), + optionId: pending.optionId, + }; + selectedTarget = thread.id; + outcome.targetId = thread.id; + } + + return { proposed }; + } finally { + context.selectedTarget = selectedTarget; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts b/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts new file mode 100644 index 00000000..46389a54 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { + applicationFrame, + batchEvents, + deferralInput, +} from "./policy-candidate-application.test-fixtures"; +import { effectivePending } from "./preference"; +import { replay } from "./domain"; +import { applyInference } from "./domain"; +import type { Event } from "./policy-types"; + +// Replay-valid histories; same-message neutral publications explicitly seed the batch. +test("constraint can defer the raw pending choice after its proposer withdraws", () => { + let { input, neutral } = deferralInput(); + expect(input.state.threads[0]!.pendingSettle).toBeDefined(); + expect(effectivePending(input.state.threads[0]!, input.state.events)).toBeUndefined(); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + "settle.deferred", + ]); + expect(frame.entry.outcome.eventIds).toHaveLength(2); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([["Jules", false, "Let's verify backups before choosing."], [ + "Mina", + true, + "Let's verify backups before choosing.", +], ["Jules", true, "Let's verify backups."]])( + "deferral retains participant, same-message and cue requirements: %s/%s/%s", + (handle, sameMessage, quote) => { + let { input, neutral } = deferralInput(handle, sameMessage, quote); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + ]); + }, +); +test("an existing current deferral is not repeated", () => { + let { input, neutral } = deferralInput(); + let deferred: Event = { + id: "already-deferred", + type: "settle.deferred", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + proposalId: "settle", + source: { ...neutral.source, role: "constraint" }, + }; + input.state = applyInference(input.state, deferred, input.message); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + ]); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test("synthetic eleven-event batch reaches thirteen because constraint deferral has no support cap", () => { + let { input, neutral } = deferralInput(); + let frame = applicationFrame(input, [...batchEvents(neutral, 10), neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events).toHaveLength(13); + expect(frame.context.events.slice(11).map(item => item.type)).toEqual([ + "constraint.added", + "settle.deferred", + ]); + expect(frame.entry.outcome.status).toBe("accepted"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts b/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts new file mode 100644 index 00000000..79ebe4e9 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { applicationFrame, supportInput } from "./policy-candidate-application.test-fixtures"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; + +test("main inference rejection keeps the original state and publications", () => { + let frame = applicationFrame(supportInput([])), before = frame.context.working; + frame.proposed.observedThreadVersion++; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.working).toBe(before); + expect(frame.context.events).toHaveLength(0); + expect(frame.entry.outcome.eventIds).toHaveLength(0); + expect(frame.entry.outcome.gate).toBe("domain rejected proposed event"); +}); + +test("a rejected reopening proposal retains the review created before application", () => { + let frame = applicationFrame(decidedInput()); + frame.proposed.observedThreadVersion++; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.reviews).toHaveLength(1); + expect(frame.context.events).toHaveLength(0); + expect(frame.entry.outcome.status).toBe("review"); + expect(frame.entry.outcome.gate).toBe("domain rejected proposed event"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts new file mode 100644 index 00000000..dd56a471 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts @@ -0,0 +1,91 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { + contributionFrame, + contributionInput, +} from "./policy-candidate-contribution.test-fixtures"; +import { runContribution } from "./policy-candidate-contribution"; +import { runStance } from "./policy-candidate-stance"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import type { Event, PolicyInput } from "./policy-types"; + +// Offline application fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts whole try/catch1634–1726. Full policy callbacks remain assigned whole. +export function supportInput( + participants: string[] = ["Jules"], + pending = false, + quote = "I support Alpha.", +) { + let input = stanceInput("support", quote); + for (let [index, handle] of participants.entries()) { + let saved = message(`prior-support-${index}`, "I support Alpha.", handle); + input.state = applyInference( + input.state, + stanceEvent(input, saved, `prior-support-${index}`, "support"), + saved, + ); + } + if (pending) pendingChoice(input); + return input; +} +export function stanceEvent( + input: PolicyInput, + saved: PolicyInput["message"], + id: string, + position: "support" | "neutral", +): Extract { + if (saved.author.kind !== "member") throw new Error("fixture requires a member source"); + return { + id, + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: saved.author, + quote: saved.text, + start: 0, + end: saved.text.length, + role: position === "neutral" ? "withdrawal" : "support", + }, + optionId: "alpha", + scopedProposalId: null, + position, + }; +} +export function deferralInput( + handle = "Jules", + sameMessage = true, + quote = "Let's verify backups before choosing.", +) { + let input = contributionInput("constraint", quote); + input.message.author = { kind: "member", handle }; + pendingChoice(input); + let saved = sameMessage + ? input.message + : message("older-withdrawal", "I withdraw that choice.", handle); + let neutral = stanceEvent(input, saved, "same-batch-neutral", "neutral"); + input.state = applyInference(input.state, neutral, saved); + return { input, neutral }; +} +export function applicationFrame(input = supportInput(), batch: Event[] = []) { + let result = contributionFrame(input); + result.context.events.push(...batch); + let proposed = result.role.role === "constraint" + ? runContribution(result.context, result.entry, result.role)?.proposed + : runStance(result.context, result.entry, result.role)?.proposed; + expect(proposed).toBeDefined(); + if (!proposed) { + throw new Error("application requires a definite proposal after the original guard"); + } + return { ...result, proposed }; +} +export function batchEvents(event: Event, count: number): Event[] { + // Synthetic publication-batch boundary; these entries do not claim a replay history. + return Array.from({ length: count }, (_, index) => ({ ...event, id: `batch-${index}` })); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-application.test.ts b/apps/server/src/conversation-plan/policy-candidate-application.test.ts new file mode 100644 index 00000000..166eadf2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { + applicationFrame, + batchEvents, + supportInput, +} from "./policy-candidate-application.test-fixtures"; +import { replay } from "./domain"; +import { decidedInput, rawChoice } from "./policy-candidate-stance.test-fixtures"; + +// Exact archived application statement1634–1726; full-policy callbacks remain deferred. +test("main inference publishes before accepting and preserves an existing gate", () => { + let frame = applicationFrame(supportInput([])); + frame.entry.outcome.gate = "retained gate"; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); + expect(frame.entry.outcome.status).toBe("accepted"); + expect(frame.entry.outcome.gate).toBe("retained gate"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([[[], 1], [["Jules"], 2], [["Mina", "Mina"], 1]] as const)( + "distinct supporters %j yield %i published events", + (participants, count) => { + let frame = applicationFrame(supportInput([...participants])); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events).toHaveLength(count); + expect(frame.context.working.threads[0]!.status).toBe(count === 2 ? "leaning" : "exploring"); + expect(frame.entry.outcome.gate).toBe("accepted"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); + }, +); +test("leaning and assent use successive working versions and the retained current reference", () => { + let frame = applicationFrame(supportInput(["Jules"], true)); + let version = frame.context.working.threads[0]!.version; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "thread.leaning", + "settle.agreed", + ]); + expect(frame.context.events.map(item => item.observedThreadVersion)).toEqual([ + version, + version + 1, + version + 2, + ]); + expect(frame.entry.outcome.eventIds).toEqual(frame.context.events.map(item => item.id)); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([[10, ["stance.changed", "thread.leaning"]], [11, ["stance.changed"]]] as const)( + "synthetic batch of %i preserves each original follow-up cap", + (count, types) => { + let frame = applicationFrame(supportInput(["Jules"], true)); + frame.context.events.push(...batchEvents(frame.proposed, count)); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.slice(count).map(item => item.type)).toEqual([...types]); + expect(frame.context.events).toHaveLength(12); + expect(frame.entry.outcome.status).toBe("accepted"); + }, +); +test.each([[0.699, false], [0.7, true]] as const)( + "raw assent score %f preserves threshold", + (score, agreed) => { + let input = supportInput([], true, "This seems sensible."); + input.candidates[0]!.answers.option = rawChoice("none"); + input.candidates[0]!.answers.chosen_option = rawChoice("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: score }; + let frame = applicationFrame(input); + expect(frame.proposed.type).toBe("stance.changed"); + if (frame.proposed.type !== "stance.changed") throw new Error("expected stance"); + expect(frame.proposed.optionId).toBeUndefined(); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.some(item => item.type === "settle.agreed")).toBe(agreed); + }, +); +test("a conflicting raw option blocks assent despite a high agreement score", () => { + let input = supportInput([], true, "This seems sensible."); + input.candidates[0]!.answers.option = rawChoice("beta"); + input.candidates[0]!.answers.chosen_option = rawChoice("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.95 }; + let frame = applicationFrame(input); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); +}); +test("captured option permits bare assent after the raw answer changes to none", () => { + let input = supportInput([], true, "yes"); + let frame = applicationFrame(input); + frame.role.option = "alpha"; // Explicit synthetic capture control; no reclassification. + input.candidates[0]!.answers.option = rawChoice("none"); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed", "settle.agreed"]); +}); +test("the proposer cannot create their own agreement", () => { + let input = supportInput([], true); + input.message.author = { kind: "member", handle: "Jules" }; + let frame = applicationFrame(input); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); +}); + +test("reopening proposals remain review outcomes and retain their existing review record", () => { + let frame = applicationFrame(decidedInput()); + expect(frame.proposed.type).toBe("candidate.proposed"); + expect(frame.context.reviews).toHaveLength(1); + frame.entry.outcome.gate = "no useful role"; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.entry.outcome.status).toBe("review"); + expect(frame.entry.outcome.gate).toBe("review"); + expect(frame.context.reviews).toHaveLength(1); + expect(frame.context.events.map(item => item.type)).toEqual(["candidate.proposed"]); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application.ts b/apps/server/src/conversation-plan/policy-candidate-application.ts new file mode 100644 index 00000000..69154b81 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.ts @@ -0,0 +1,119 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { activeSettleDeferral, effectivePending } from "./preference"; + +/** Invoke after the original no-proposal guard; the driver retains its own event cap. */ +export function applyCandidateEvent( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, + proposed: Event, +): void { + let { message, events } = context; + let { candidate, outcome } = entry; + let { option } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + try { + working = applyInference(working, proposed, message); + events.push(proposed); + outcome.eventIds.push(proposed.id); + outcome.status = proposed.type === "candidate.proposed" ? "review" : "accepted"; + if (outcome.gate === "no useful role") outcome.gate = outcome.status; + let participant = message.author.kind === "member" ? message.author.handle : undefined; + if ( + proposed.type === "constraint.added" && participant + && /\b(?:verify|check|test)\b.*\bbefore\b.*\b(?:choos|decid|sav|settl)/i + .test(candidate.quote) + && events.some(item => + item.type === "stance.changed" && item.threadId === proposed.threadId + && item.source.messageId === message.id && item.position === "neutral" + && item.source.author.kind === "member" + && item.source.author.handle === participant + ) + ) { + let proposer = participant; + let current = working.threads.find(item => item.id === proposed.threadId); + let pending = current?.pendingSettle; + let proposal = pending + && working.events.findLast(item => + item.type === "settle.suggested" && item.threadId === proposed.threadId + && item.optionId === pending.optionId + && item.source.messageId === pending.messageId + ); + if ( + current && pending && proposal?.type === "settle.suggested" + && pending.proposer === proposer + && !activeSettleDeferral(current, working.events) + ) { + let deferred: Event = { + ...base(current.id, "settle.deferred"), + type: "settle.deferred", + proposalId: proposal.id, + source: source("constraint"), + }; + working = applyInference(working, deferred, message); + events.push(deferred); + outcome.eventIds.push(deferred.id); + } + } + if ( + proposed.type === "stance.changed" && proposed.position === "support" + && events.length < 12 + ) { + let current = working.threads.find((item) => item.id === proposed.threadId); + let supporters = proposed.optionId && new Set( + current?.stances.filter((item) => + item.optionId === proposed.optionId && item.position === "support" + ).map((item) => item.participant), + ); + if (current?.status === "exploring" && supporters && supporters.size >= 2) { + let leaning: Event = { + ...base(current.id, "thread.leaning"), + type: "thread.leaning", + source: proposed.source, + optionId: proposed.optionId, + }; + working = applyInference(working, leaning, message); + events.push(leaning); + outcome.eventIds.push(leaning.id); + } + let pending = current && effectivePending(current, working.events); + let namedOption = candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : undefined; + if ( + current && pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!namedOption || namedOption === "none" || namedOption === pending.optionId) + && (proposed.optionId === pending.optionId + || option === pending.optionId + && /^(?:yes|yep|agreed|i agree)[,!.]?$/iu.test(candidate.quote.trim()) + || noul(candidate.answers, "agrees_with_settle") >= 0.7) + && events.length < 12 + ) { + let agreed: Event = { + ...base(current.id, "settle.agreed"), + type: "settle.agreed", + source: proposed.source, + optionId: pending.optionId, + }; + working = applyInference(working, agreed, message); + events.push(agreed); + outcome.eventIds.push(agreed.id); + } + } + } catch { + outcome.status = "review"; + outcome.gate = "domain rejected proposed event"; + } + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts new file mode 100644 index 00000000..8bf6de00 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts @@ -0,0 +1,51 @@ +import { expect } from "bun:test"; +import { applyEvent } from "./events"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { cardId, pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequent1370–1431. +// Complete downstream-dependent callbacks remain assigned whole. +export function contributionInput( + role = "reason", + quote = "Batched delivery reduces latency.", +): PolicyInput { + let input = roleInput(role, quote); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + return input; +} +export function linkContributionCard(input: PolicyInput) { + input.state = applyEvent(input.state, { + id: "card-linked", + type: "card.linked", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }], + }]]); +} +export function qualifiedInput() { + let input = contributionInput("constraint", "This requires a retry budget."); + pendingChoice(input); + input.candidates[0]!.answers.qualifies_pending_settle = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.relation = confidentChoice("qualifies"); + return input; +} +export function contributionFrame(input = contributionInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + expect(role).toBeDefined(); + return { context, entry, role }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts new file mode 100644 index 00000000..03e76294 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts @@ -0,0 +1,191 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + contributionFrame, + contributionInput, + linkContributionCard, + qualifiedInput, +} from "./policy-candidate-contribution.test-fixtures"; +import { runContribution } from "./policy-candidate-contribution"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { withdrawCurrent } from "./policy-candidate-ordinary.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { optionIdFor, stableId } from "./policy-identity"; + +test.each(["option", "reason", "constraint"])( + "%s preserves exact quoted proposal without application", + roleName => { + let input = contributionInput(roleName); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + let before = structuredClone(context.working); + expect(role.role).toBe(roleName); + let result = runContribution(context, entry, role); + expect(result?.proposed).toMatchObject({ + type: roleName + ".added", + source: { quote: entry.candidate.quote, role: roleName }, + contribution: { + text: entry.candidate.quote, + authoring: "quoted", + targetId: "alpha", + relation: "supports", + }, + }); + let proposed = result!.proposed!; + expect("contribution" in proposed && proposed.contribution.id).toBe( + roleName === "option" + ? optionIdFor(input.channelId, input.message.id, 0, input.message.ts) + : stableId(input.channelId, input.message.id, 0, "contribution"), + ); + let accepted = applyInference(input.state, proposed, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); + expect(context.reviews).toHaveLength(0); + expect(entry.outcome.eventIds).toHaveLength(0); + }, +); +test.each([ + "Alpha, Beta, or Gamma", + "Which provider fits?", + "Alpha is quick but Beta is flexible.", +])("unsafe option quote stays usable as a reason/constraint: %s", quote => { + for (let name of ["option", "reason", "constraint"]) { + let { context, entry, role } = contributionFrame(contributionInput(name, quote)); + let result = runContribution(context, entry, role); + if (name === "option") { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe( + "option source contains a question, group, or contrastive evaluation", + ); + } else { + let expected: "reason.added" | "constraint.added" = name === "reason" + ? "reason.added" + : "constraint.added"; + expect(result?.proposed?.type).toBe(expected); + } + } +}); +test.each([0.799, 0.8])( + "duplicate threshold %s preserves ignored outcome on refusal", + probability => { + let input = contributionInput(); + input.candidates[0]!.answers.duplicate = { type: "noul", noul: probability }; + let { context, entry, role } = contributionFrame(input); + let result = runContribution(context, entry, role); + if (probability === 0.8) { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("duplicate contribution"); + expect(entry.outcome.status).toBe("ignored"); + } else expect(result?.proposed?.type).toBe("reason.added"); + }, +); +test("cached option group exempts the duplicate guard", () => { + let { context, entry, role } = contributionFrame(); + entry.candidate.answers.duplicate = { type: "noul", noul: 1 }; + context.candidateRun!.optionGroup = "provider"; + expect(runContribution(context, entry, role)?.proposed?.type).toBe("reason.added"); + expect(context.events).toHaveLength(0); +}); +test("missing captured thread is handled without inventing an event", () => { + let input = contributionInput(); + input.candidates[0]!.answers.thread = confidentChoice("missing"); + let { context, entry, role } = contributionFrame(input); + expect(role.thread).toBeUndefined(); + expect(runContribution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("contribution target needs review"); + expect(context.events).toHaveLength(0); +}); +test.each(["matching", "wrong-card", "ambiguous"])( + "existing option name/card uniqueness: %s", + variant => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + if (variant === "wrong-card") input.linkedCards!.get("provider")!.cardId = "replacement"; + if (variant === "ambiguous") { + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { id: "other", label: "Alpha" }], + }]]); + } + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + let result = runContribution(context, entry, role); + if (variant === "matching") { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("proposal names an existing card option"); + } else expect(result?.proposed?.type).toBe("option.added"); + }, +); +test("fresh card map replacement overrides the earlier captured linked card", () => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + let { context, entry, role } = contributionFrame(input); + let captured = role.linkedCard; + context.input.linkedCards = new Map([["provider", { cardId: "replacement", options: [] }]]); + expect(runContribution(context, entry, role)?.proposed?.type).toBe("option.added"); + expect(role.linkedCard).toBe(captured); +}); +test("freshly restored matching card is read after an earlier mismatched capture", () => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + let saved = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { ...saved, cardId: "stale" }]]); + let { context, entry, role } = contributionFrame(input); + context.input.linkedCards = new Map([["provider", saved]]); + expect(role.linkedCard!.cardId).toBe("stale"); + expect(runContribution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("proposal names an existing card option"); +}); +test.each(["alpha", "missing", "none"])( + "target option %s uses captured contributions or thread fallback", + option => { + let input = contributionInput(); + input.candidates[0]!.answers.option = confidentChoice(option); + let { context, entry, role } = contributionFrame(input); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.targetId).toBe( + option === "alpha" ? "alpha" : "provider", + ); + }, +); +test.each(["supports", "challenges", "qualifies", "blocks"])("relation allowlist: %s", relation => { + let input = contributionInput(); + input.candidates[0]!.answers.relation = confidentChoice(relation); + let { context, entry, role } = contributionFrame(input); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.relation).toBe( + relation === "blocks" ? undefined : relation, + ); + if (relation === "blocks") { + expect("contribution" in event && Object.hasOwn(event.contribution, "relation")).toBe(false); + } +}); +test("qualified pending forces its captured option and relation despite later answers", () => { + let input = qualifiedInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + expect(role.qualifiedPending).toBeTruthy(); + context.working = withdrawCurrent(input); + expect(replay(context.working.events)).toEqual(context.working); + entry.candidate.answers.option = confidentChoice("missing"); + entry.candidate.answers.relation = confidentChoice("challenges"); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution).toMatchObject({ + targetId: "alpha", + relation: "qualifies", + }); +}); + +test("target selection keeps captured contributions after working-thread replacement", () => { + let { context, entry, role } = contributionFrame(); + let captured = role.thread; + context.working = stateWithOption("Which provider?", "provider", "beta", "Beta"); + expect(replay(context.working.events)).toEqual(context.working); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.targetId).toBe("alpha"); + expect(role.thread).toBe(captured); + expect(context.working.threads[0]!.contributions[0]!.id).toBe("beta"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.ts new file mode 100644 index 00000000..761477a4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.ts @@ -0,0 +1,89 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { COMMA_ALTERNATIVES, namesCardOption } from "./policy-cues"; +import { optionIdFor, stableId } from "./policy-identity"; +import { choice, moderateChoice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; + +/** Invoke only for the captured option/reason/constraint role; a result progresses without application. */ +export function runContribution( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { input, channelId, message, first } = context; + let { index, candidate, outcome } = entry; + let { thread, option, pending, qualifiedPending, role } = frame; + let { optionGroup } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if ( + role === "option" + && (COMMA_ALTERNATIVES.test(candidate.quote.trim()) + || /^(?:what|which|who|where|when|why|how)\b[^?]*\?$/i.test(candidate.quote.trim()) + || /\b[\p{L}][\p{L}\p{N}-]*\s+is\s+[^.!?]{1,120}\s+but\s+[^.!?]{1,120}[.!?]?$/iu + .test(candidate.quote.trim())) + ) { + outcome.status = "review"; + outcome.gate = "option source contains a question, group, or contrastive evaluation"; + return undefined; + } + if (!thread) { + outcome.status = "review"; + outcome.gate = "contribution target needs review"; + return undefined; + } + if ( + role === "option" && message.author.kind === "member" + && moderateChoice(first, "act", "proposal", 0.7) + && option && !["new", "none"].includes(option) + ) { + let card = input.linkedCards?.get(thread.id); + let named = card && card.cardId === thread.questionnaireId + ? card.options.filter(item => namesCardOption(candidate.quote, item.label)) + : []; + if (named.length === 1 && named[0]?.id === option) { + outcome.status = "review"; + outcome.gate = "proposal names an existing card option"; + return undefined; + } + } + // The new group has no prior contributions; repeated labels were skipped above. + if (!optionGroup && noul(candidate.answers, "duplicate") >= 0.8) { + outcome.gate = "duplicate contribution"; + return undefined; + } + let type = `${role}.added` as "option.added" | "reason.added" | "constraint.added"; + let relation = qualifiedPending && role === "constraint" + ? "qualifies" + : choice(candidate.answers, "relation"); + let existingOption = qualifiedPending && role === "constraint" + ? pending?.optionId + : option && !["new", "none"].includes(option) + && thread.contributions.some((item) => item.id === option && item.kind === "option") + ? option + : undefined; + proposed = { + ...base(thread.id, type), + type, + source: source(role as ConversationPlan.SourceRole), + contribution: { + id: role === "option" + ? optionIdFor(channelId, message.id, index, message.ts) + : stableId(channelId, message.id, index, "contribution"), + text: candidate.quote, + authoring: "quoted", + targetId: existingOption ?? thread.id, + ...(["supports", "challenges", "qualifies"].includes(relation ?? "") + ? { relation: relation as "supports" | "challenges" | "qualifies" } + : {}), + }, + }; + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts new file mode 100644 index 00000000..e8a9cd6e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts @@ -0,0 +1,38 @@ +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { choiceInput, groupInput, prepareCandidateContext } from "./policy-candidate.test-fixtures"; +import { confidentChoice, follow } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline entry/role fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements878–1091. Downstream full-policy callbacks remain assigned. +export function candidateContext(input: PolicyInput = groupInput()) { + let context = prepareCandidateContext(input); + prepareCandidateRun(context); + return context; +} + +export function roleInput(role = "reason", quote = "This reduces latency."): PolicyInput { + let input = choiceInput(["alpha"]); + let span = groupInput([quote]); + input.message = span.message; + input.first.significance = { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful" }, + probabilities: { "2": 1 }, + }; + input.first.reason = { type: "noul", noul: 0.95 }; + input.candidates[0] = { + ...span.candidates[0]!, + answers: { + ...follow({ role, thread: "provider" }), + option: confidentChoice("alpha"), + chosen_option: confidentChoice("alpha"), + relation: confidentChoice("supports"), + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + }; + return input; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.test.ts b/apps/server/src/conversation-plan/policy-candidate-entry.test.ts new file mode 100644 index 00000000..2cf90c7d --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { groupInput } from "./policy-candidate.test-fixtures"; +import { capEvents } from "./policy-terminal.test-fixtures"; +import { MAX_EVENTS } from "./validation-fields"; + +test.each(["mismatched span", "empty quote", "oversized quote"])( + "pushes outcome before rejecting %s", + kind => { + let context = candidateContext(roleInput()); + let candidate = context.input.candidates[0]!; + if (kind === "mismatched span") candidate.start += 1; + if (kind === "empty quote") { + candidate.quote = ""; + candidate.end = candidate.start; + } + if (kind === "oversized quote") { + candidate.quote = "x".repeat(2049); + context.message.text = candidate.quote; + candidate.start = 0; + candidate.end = candidate.quote.length; + } + context.first.c0_owned_unretracted = { type: "noul", noul: 0 }; + expect(beginCandidate(context, 0, candidate)).toBeUndefined(); + expect(context.outcomes).toEqual([{ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "invalid source quote", + eventIds: [], + }]); + expect(context.events).toHaveLength(0); + }, +); + +test("ownership rejection follows source validation and keeps its ignored outcome", () => { + let context = candidateContext(roleInput()); + context.first.c0_owned_unretracted = { type: "noul", noul: 0.69 }; + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeUndefined(); + expect(context.outcomes[0]!.gate).toBe("source ownership unclear"); +}); + +test("opening updates working and event IDs before consuming the first cached label", () => { + let context = candidateContext(); + let before = structuredClone(context.input); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + expect(entry.outcome).toBe(context.outcomes[0]!); + expect(entry.candidate).toBe(context.input.candidates[0]!); + expect(entry.index).toBe(0); + expect(context.events.map(event => event.type)).toEqual(["thread.opened"]); + expect(entry.outcome.eventIds).toEqual([context.events[0]!.id]); + expect(context.working.threads[0]!.id).toBe(context.candidateRun!.optionGroup!); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); + expect(context.input).toEqual(before); +}); + +test("domain failure clears the group and keeps later ownership checks active", () => { + let input = groupInput(); + capEvents(input, MAX_EVENTS); + let context = candidateContext(input); + let oldWorking = context.working; + expect(beginCandidate(context, 0, input.candidates[0]!)).toBeUndefined(); + expect(context.working).toBe(oldWorking); + expect(context.candidateRun!.optionGroup).toBeUndefined(); + expect(context.outcomes[0]!.status).toBe("review"); + expect(context.outcomes[0]!.gate).toBe("multi-option question could not be opened"); + context.first.c1_owned_unretracted = { type: "noul", noul: 0 }; + expect(beginCandidate(context, 1, input.candidates[1]!)).toBeUndefined(); + expect(context.outcomes[1]!.gate).toBe("source ownership unclear"); +}); + +test("duplicate cached labels skip while the group opening and consumed labels survive", () => { + let context = candidateContext(); + beginCandidate(context, 0, context.input.candidates[0]!); + context.facts!.optionLabels[1] = "alpha"; + expect(beginCandidate(context, 1, context.input.candidates[1]!)).toBeUndefined(); + expect(context.outcomes[1]!.gate).toBe("duplicate option in new question"); + expect(context.events).toHaveLength(1); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); +}); + +test("entry progression does not add a twelve-event guard", () => { + let context = candidateContext(roleInput()); + let existing = candidateContext(); + beginCandidate(existing, 0, existing.input.candidates[0]!); + context.events.push(...Array.from({ length: 12 }, () => existing.events[0]!)); + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeDefined(); + expect(context.outcomes).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.ts b/apps/server/src/conversation-plan/policy-candidate-entry.ts new file mode 100644 index 00000000..11ec71ff --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.ts @@ -0,0 +1,89 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { noul } from "./policy-scoring"; +import type { CandidateJudgment, Event } from "./policy-types"; + +export type CandidateEntry = { + index: number; + candidate: CandidateJudgment; + outcome: ConversationPlan.CandidateOutcome; +}; + +/** Undefined skips this candidate; setup must already exist on the captured context. */ +export function beginCandidate( + context: PolicyContext, + index: number, + candidate: CandidateJudgment, +): CandidateEntry | undefined { + let { channelId, message, first, events, outcomes } = context; + let { optionLabels } = context.facts!; + let { seenOptionLabels } = context.candidateRun!; + let optionGroup = context.candidateRun!.optionGroup; + let working = context.working; + try { + let outcome: ConversationPlan.CandidateOutcome = { + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "no useful role", + eventIds: [], + }; + outcomes.push(outcome); + if ( + message.text.slice(candidate.start, candidate.end) !== candidate.quote + || !candidate.quote || candidate.quote.length > 2048 + ) { + outcome.gate = "invalid source quote"; + return undefined; + } + if (!optionGroup && noul(first, `c${index}_owned_unretracted`) < OWNED_QUOTE_MIN) { + outcome.gate = "source ownership unclear"; + return undefined; + } + if (optionGroup && index === 0) { + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId: optionGroup, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + working = applyInference(working, opening, message); + events.push(opening); + outcome.eventIds.push(opening.id); + } catch { + optionGroup = undefined; + outcome.status = "review"; + outcome.gate = "multi-option question could not be opened"; + return undefined; + } + } + if (optionGroup) { + let label = optionLabels[index]!; + if (seenOptionLabels.has(label)) { + outcome.gate = "duplicate option in new question"; + return undefined; + } + seenOptionLabels.add(label); + } + return { index, candidate, outcome }; + } finally { + context.working = working; + context.candidateRun!.optionGroup = optionGroup; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-factories.test.ts b/apps/server/src/conversation-plan/policy-candidate-factories.test.ts new file mode 100644 index 00000000..3517c734 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-factories.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import { + verificationFrame, + verificationInput, +} from "./policy-candidate-verification.test-fixtures"; +import { stableId } from "./policy-identity"; + +test.each(["member", "agent"])("factories retain exact %s source and base metadata", author => { + let input = verificationInput(); + if (author === "agent") input.message.author = { kind: "agent" }; + let { context, entry } = verificationFrame(input); + let factories = createCandidateFactories(context, entry, () => context.working); + if (input.message.author.kind === "system") throw new Error("expected source author"); + expect(factories.source("verification")).toEqual({ + messageId: input.message.id, + author: input.message.author, + quote: entry.candidate.quote, + start: entry.candidate.start, + end: entry.candidate.end, + role: "verification", + }); + expect(factories.base("provider", "settle.resumed")).toEqual({ + id: stableId(input.channelId, input.message.id, entry.index, "settle.resumed"), + threadId: "provider", + observedThreadVersion: context.working.threads[0]!.version, + origin: author === "agent" ? "planner" : "classifier", + actor: { kind: author === "agent" ? "agent" : "classifier" }, + at: input.message.ts, + }); + expect(factories.base("missing", "reason.added").observedThreadVersion).toBe(0); +}); + +test("base reads active local working on every call after a real inferred update", () => { + let { context, entry } = verificationFrame(); + let working = context.working; + let reads = 0; + let factories = createCandidateFactories(context, entry, () => { + reads++; + return working; + }); + expect(reads).toBe(0); + factories.source("constraint"); + expect(reads).toBe(0); + let before = factories.base("provider", "constraint.added"); + working = applyInference(working, { + ...before, + type: "constraint.added", + source: factories.source("constraint"), + contribution: { + id: "constraint", + text: entry.candidate.quote, + authoring: "quoted", + targetId: "provider", + }, + }, context.message); + let after = factories.base("provider", "settle.resumed"); + expect(after.observedThreadVersion).toBe(before.observedThreadVersion + 1); + expect(context.working.threads[0]!.version).toBe(before.observedThreadVersion); + expect(reads).toBe(2); +}); + +test("source keeps captured object references while base retains candidate-index identity", () => { + let { context, entry } = verificationFrame(); + entry.index = 2; + let factories = createCandidateFactories(context, entry, () => context.working); + let originalMessage = context.message; + let originalCandidate = entry.candidate; + context.input.message = { ...originalMessage, id: "replacement" }; + context.input.candidates[0] = { ...originalCandidate, quote: "replacement" }; + originalCandidate.quote = "Captured object changed."; + originalMessage.ts = 1001; + expect(factories.source("reason").quote).toBe("Captured object changed."); + if (originalMessage.author.kind === "system") throw new Error("expected source author"); + expect(factories.source("reason").author).toBe(originalMessage.author); + expect(factories.source("reason").messageId).toBe(originalMessage.id); + expect(factories.base("provider", "reason.added").id).toBe( + stableId("channel", originalMessage.id, 2, "reason.added"), + ); + expect(factories.base("provider", "reason.added").at).toBe(1001); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-factories.ts b/apps/server/src/conversation-plan/policy-candidate-factories.ts new file mode 100644 index 00000000..e6366562 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-factories.ts @@ -0,0 +1,38 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { State } from "./policy-types"; + +/** Bind readWorking to the active handler's local working, never a finished handler or snapshot. */ +export function createCandidateFactories( + context: PolicyContext, + entry: CandidateEntry, + readWorking: () => State, +) { + let { channelId, message } = context; + let { index, candidate } = entry; + let source = (sourceRole: ConversationPlan.SourceRole): ConversationPlan.SourceRef => ({ + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: sourceRole, + }); + return { + source, + base: (threadId: string, type: string): ConversationPlan.EventBase => { + let working = readWorking(); + let base = (threadId: string, type: string): ConversationPlan.EventBase => ({ + id: stableId(channelId, message.id, index, type), + threadId, + observedThreadVersion: working.threads.find((item) => item.id === threadId)?.version ?? 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + }); + return base(threadId, type); + }, + }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts b/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts new file mode 100644 index 00000000..800b112f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { runDirectNewChoice } from "./policy-candidate-new-choice"; +import { + newChoiceInput, + pendingChoice, + scopedFrame, +} from "./policy-candidate-scoped.test-fixtures"; +import { effectivePending } from "./preference"; +import { optionIdFor } from "./policy-identity"; + +test.each([false, true])( + "direct new choice stages the exact option with competing=%s", + competing => { + let input = newChoiceInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + if (competing) context.candidateRun!.competingMessageTargets.add("provider"); + let before = structuredClone(input); + let version = context.working.threads[0]!.version; + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.type)).toEqual( + competing ? ["option.added"] : ["option.added", "settle.suggested"], + ); + let option = context.events[0]!; + if (option.type !== "option.added") throw new Error("expected option"); + expect(option.contribution.id).toBe( + optionIdFor("channel", input.message.id, 0, input.message.ts), + ); + expect(option.observedThreadVersion).toBe(version); + if (!competing) expect(context.events[1]!.observedThreadVersion).toBe(version + 1); + expect(entry.outcome.status).toBe(competing ? "review" : "accepted"); + expect(entry.outcome.gate).toBe(competing ? "competing choice needs review" : "accepted"); + expect(input).toEqual(before); + expect(replay(context.working.events)).toEqual(context.working); + }, +); + +test.each([false, true])( + "effective pending choice controls the skip after withdrawn=%s", + withdrawn => { + let input = newChoiceInput(); + pendingChoice(input, withdrawn); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + expect(role.thread!.pendingSettle).toBeDefined(); + expect(Boolean(effectivePending(role.thread!, context.working.events))).toBe(!withdrawn); + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(context.events).toHaveLength(withdrawn ? 2 : 0); + expect(entry.outcome.gate).toBe(withdrawn ? "accepted" : "competing choice needs review"); + }, +); + +// Batch-length controls do not claim replay consistency for repeated synthetic prior events. +test.each([10, 11])("preserves the original <=10 handler bound at %s prior batch events", count => { + let { context, entry, role } = scopedFrame(newChoiceInput()); + context.events.push(...Array.from({ length: count }, () => context.working.events[0]!)); + let result = runDirectNewChoice(context, entry, role); + expect(result).toBe(count === 10 ? undefined : true); + expect(context.events).toHaveLength(count === 10 ? 12 : 11); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-new-choice.ts b/apps/server/src/conversation-plan/policy-candidate-new-choice.ts new file mode 100644 index 00000000..2054c53a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-new-choice.ts @@ -0,0 +1,73 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { optionIdFor } from "./policy-identity"; +import type { Event } from "./policy-types"; +import { effectivePending } from "./preference"; + +/** Requires both scoped handlers to progress. Undefined handles/skips; true reaches later roles. */ +export function runDirectNewChoice( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): true | undefined { + let { channelId, message, events } = context; + let { index, candidate, outcome } = entry; + let { thread, directNewChoice } = frame; + let { competingMessageTargets } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + if (directNewChoice && thread && events.length <= 10) { + if (effectivePending(thread, working.events)) { + outcome.status = "review"; + outcome.gate = "competing choice needs review"; + return undefined; + } + let optionId = optionIdFor(channelId, message.id, index, message.ts); + let competing = competingMessageTargets.has(thread.id); + let added: Event = { + ...base(thread.id, "option.added"), + type: "option.added", + source: source("option"), + contribution: { + id: optionId, + text: candidate.quote, + authoring: "quoted", + targetId: thread.id, + }, + }; + try { + let staged = applyInference(working, added, message); + if (!competing) { + let stagedThread = staged.threads.find(item => item.id === thread.id)!; + let suggested: Event = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + observedThreadVersion: stagedThread.version, + source: source("resolution"), + optionId, + }; + staged = applyInference(staged, suggested, message); + events.push(added, suggested); + outcome.eventIds.push(added.id, suggested.id); + } else { + events.push(added); + outcome.eventIds.push(added.id); + } + working = staged; + outcome.status = competing ? "review" : "accepted"; + outcome.gate = competing ? "competing choice needs review" : "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "new choice could not be suggested"; + } + return undefined; + } + return true; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts new file mode 100644 index 00000000..b0bf57e2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts @@ -0,0 +1,68 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequents1298–1368. +// Complete downstream-dependent callbacks remain assigned to the full policy. +export function ordinaryInput( + role: "withdrawal" | "none" | "question" = "withdrawal", +): PolicyInput { + let input = roleInput( + role, + role === "withdrawal" + ? "I withdraw that choice." + : role === "none" + ? "I agree with that choice." + : "Should retries survive a restart?", + ); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.first.new_question = { type: "noul", noul: 0.6 }; + input.first.withdrawal = { type: "noul", noul: 0.95 }; + input.candidates[0]!.answers.withdraws_pending_settle = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.7 }; + if (role !== "question") { + pendingChoice(input); + if (role === "withdrawal") input.message.author = { kind: "member", handle: "Jules" }; + } else { + input.first.thread_target = confidentChoice("new"); + input.candidates[0]!.answers.thread = confidentChoice("new"); + } + return input; +} +export function ordinaryFrame(input = ordinaryInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + expect(role).toBeDefined(); + return { context, entry, role }; +} +export function withdrawCurrent(input: PolicyInput) { + let saved = message("later-withdrawal", "I withdraw that choice.", "Jules"); + return applyInference(input.state, { + id: "later-withdrawal", + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "withdrawal", + }, + optionId: "alpha", + scopedProposalId: null, + position: "neutral", + }, saved); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-question.test.ts b/apps/server/src/conversation-plan/policy-candidate-question.test.ts new file mode 100644 index 00000000..7ce9098a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-question.test.ts @@ -0,0 +1,68 @@ +import { applyEvent } from "./events"; +import { replay } from "./domain"; +import { expect, test } from "bun:test"; +import { ordinaryFrame, ordinaryInput } from "./policy-candidate-ordinary.test-fixtures"; +import { runQuestion } from "./policy-candidate-question"; + +test.each([0.6, 0.599])("question threshold %s preserves proposal vs review", probability => { + let input = ordinaryInput("question"); + input.first.new_question = { type: "noul", noul: probability }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("question"); + let result = runQuestion(context, entry, role); + expect(result).toBeDefined(); + if (probability === 0.6) { + expect(result?.proposed).toMatchObject({ type: "thread.opened", question: input.message.text }); + expect(entry.outcome.targetId).toBe(result?.proposed?.threadId); + } else { + expect(result?.proposed).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + } + expect(context.events).toHaveLength(0); +}); +test("cached direct-question skips without recomputing from changed first answers", () => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("question")); + context.directQuestion = true; + context.first.new_question = { type: "noul", noul: 0 }; + expect(runQuestion(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("no useful role"); +}); +test.each([{ first: 0.9, again: 0.7 }, { first: 0.899, again: 0.7 }, { first: 0.9, again: 0.699 }])( + "discarded-question bounds $first/$again", + ({ first, again }) => { + let input = ordinaryInput("question"); + input.candidates[0]!.answers.thread = { + type: "choice", + choice: "provider", + confidence: 1, + probabilities: { provider: 1 }, + }; + input.state = applyEvent(input.state, { + id: "discard-provider", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1000, + }); + expect(replay(input.state.events)).toEqual(input.state); + input.first.new_question = { type: "noul", noul: first }; + input.candidates[0]!.answers.raises_again = { type: "noul", noul: again }; + let { context, entry, role } = ordinaryFrame(input); + let result = runQuestion(context, entry, role); + if (first === 0.9 && again === 0.7) expect(result?.proposed?.type).toBe("thread.opened"); + else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("discarded question not raised again"); + } + }, +); + +test("a cached direct question progresses when an option group already exists", () => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("question")); + context.directQuestion = true; + context.candidateRun!.optionGroup = "provider"; + expect(runQuestion(context, entry, role)?.proposed?.type).toBe("thread.opened"); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-question.ts b/apps/server/src/conversation-plan/policy-candidate-question.ts new file mode 100644 index 00000000..25c1a4d8 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-question.ts @@ -0,0 +1,48 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; +import { stableId } from "./policy-identity"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runQuestion( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, directQuestion } = context; + let { index, candidate, outcome } = entry; + let { thread, target } = frame; + let { optionGroup } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if (directQuestion && !optionGroup) return undefined; + let raisesDiscarded = thread?.status === "discarded"; + if ( + raisesDiscarded + && (noul(first, "new_question") < 0.9 + || noul(candidate.answers, "raises_again") < 0.7) + ) { + outcome.gate = "discarded question not raised again"; + return undefined; + } + if ((target === "new" && noul(first, "new_question") >= 0.6) || raisesDiscarded) { + let threadId = `thread:${stableId(channelId, message.id, index, "thread").slice(11)}`; + outcome.targetId = threadId; + proposed = { + ...base(threadId, "thread.opened"), + type: "thread.opened", + source: source("question"), + question: candidate.quote, + }; + } else { + outcome.status = "review"; + outcome.gate = "question target needs review"; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts b/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts new file mode 100644 index 00000000..1d9a1406 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { reopeningInput, resolutionFrame } from "./policy-candidate-resolution.test-fixtures"; +import { runReopening } from "./policy-candidate-reopening"; + +test.each([{ first: 0.8, follow: 0.8 }, { first: 0.799, follow: 0.8 }, { + first: 0.8, + follow: 0.799, +}])("explicit reopening thresholds $first/$follow", ({ first, follow }) => { + let input = reopeningInput(); + input.first.reopening = { type: "noul", noul: first }; + input.candidates[0]!.answers.reopening = { type: "noul", noul: follow }; + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let result = runReopening(context, entry, role); + expect(result).toBeDefined(); + if (first === 0.8 && follow === 0.8) { + expect(result?.proposed?.type).toBe("candidate.proposed"); + expect(context.reviews).toHaveLength(1); + } else { + expect(result?.proposed).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening needs review"); + expect(context.reviews).toHaveLength(0); + } + expect(context.events).toHaveLength(0); +}); +test("duplicate reopening review is an explicit handled skip", () => { + let { context, entry, role } = resolutionFrame(reopeningInput()); + context.reviews.push({ id: "existing", kind: "reopening", targetId: "provider" }); + expect(runReopening(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening review already proposed from this message"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-reopening.ts b/apps/server/src/conversation-plan/policy-candidate-reopening.ts new file mode 100644 index 00000000..4406044f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-reopening.ts @@ -0,0 +1,47 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { Event } from "./policy-types"; +import { directReopening } from "./policy-cues"; +import { noul } from "./policy-scoring"; + +/** Invoke only for matching captured reopening; a result progresses without ordinary application. */ +export function runReopening( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, reviews } = context; + let { thread } = frame; + let { index, candidate, outcome } = entry; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if ( + thread?.status === "decided" && message.author.kind === "member" + && noul(first, "reopening") >= 0.8 + && noul(candidate.answers, "reopening") >= 0.8 && directReopening(candidate.quote) + ) { + if (reviews.some((item) => item.kind === "reopening" && item.targetId === thread.id)) { + outcome.status = "review"; + outcome.gate = "reopening review already proposed from this message"; + return undefined; + } + let id = stableId(channelId, message.id, index, "reopening-candidate"); + proposed = { + ...base(thread.id, "candidate.proposed"), + type: "candidate.proposed", + source: source("reopening"), + candidate: { id, kind: "reopening", text: candidate.quote }, + }; + reviews.push({ id, kind: "reopening", targetId: thread.id }); + } else { + outcome.status = "review"; + outcome.gate = "reopening needs review"; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts b/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts new file mode 100644 index 00000000..e2ccc9b4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { resolutionFrame, resolutionInput } from "./policy-candidate-resolution.test-fixtures"; +import { runResolution } from "./policy-candidate-resolution"; + +test("linked card materialization stages versions then publishes the applied state", () => { + let input = resolutionInput(true); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let version = role.thread!.version; + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.observedThreadVersion)).toEqual([version, version + 1]); + expect(context.events[0]!).toMatchObject({ + type: "option.added", + source: { role: "option" }, + contribution: { id: "beta", text: "Beta", authoring: "scribe", targetId: "provider" }, + }); + expect(context.events[1]!).toMatchObject({ + type: "settle.suggested", + source: { role: "resolution" }, + optionId: "beta", + }); + expect(context.working.threads[0]!.version).toBe(version + 2); + expect(entry.outcome.status).toBe("accepted"); + expect(entry.outcome.eventIds).toEqual(context.events.map(event => event.id)); + expect(replay(context.working.events)).toEqual(context.working); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts new file mode 100644 index 00000000..896322a7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts @@ -0,0 +1,38 @@ +import { + contributionFrame, + contributionInput, + linkContributionCard, +} from "./policy-candidate-contribution.test-fixtures"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequents1532–1631. +// Original full-policy callbacks remain assigned whole. +export function resolutionInput(materialize = false): PolicyInput { + let option = materialize ? "beta" : "alpha"; + let input = contributionInput("resolution", `Let's use ${materialize ? "Beta" : "Alpha"}.`); + linkContributionCard(input); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { id: "beta", label: "Beta" }], + }]]); + input.first.explicit_resolution = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0.85 }; + input.candidates[0]!.answers.option = confidentChoice(option); + input.candidates[0]!.answers.chosen_option = confidentChoice(option); + return input; +} +export function reopeningInput() { + let input = contributionInput("reopening", "Let's revisit the provider choice."); + input.state = decidedInput().state; + input.first.reopening = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.reopening = { type: "noul", noul: 0.8 }; + return input; +} +export function resolutionFrame(input = resolutionInput()) { + return contributionFrame(input); +} +export { confidentChoice }; diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts new file mode 100644 index 00000000..2de554ae --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { applyEvent } from "./events"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { + confidentChoice, + resolutionFrame, + resolutionInput, +} from "./policy-candidate-resolution.test-fixtures"; +import { runResolution } from "./policy-candidate-resolution"; + +test.each([{ marker: 0.8, follow: 0.85 }, { marker: 0.799, follow: 0.85 }, { + marker: 0.8, + follow: 0.849, +}])("credibility markers $marker/$follow", ({ marker, follow }) => { + let input = resolutionInput(); + input.first.explicit_resolution = { type: "noul", noul: marker }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: follow }; + let { context, entry, role } = resolutionFrame(input); + expect(role.role).toBe("resolution"); + let result = runResolution(context, entry, role); + if (marker === 0.8 && follow === 0.85) { + expect(result?.proposed).toMatchObject({ + type: "settle.suggested", + source: { role: "resolution" }, + optionId: "alpha", + }); + let accepted = applyInference(input.state, result!.proposed!, input.message); + expect(replay(accepted.events)).toEqual(accepted); + } else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle authority unclear"); + } + expect(context.events).toHaveLength(0); +}); +test("commitment supplies credibility without resolution markers", () => { + let input = resolutionInput(); + input.first.act = confidentChoice("commitment"); + input.first.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0 }; + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)?.proposed?.type).toBe("settle.suggested"); +}); +test("captured strong recommendation survives later classifier changes", () => { + let input = resolutionInput(); + input.message.text = "We should use Alpha."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + input.first.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { context, entry, role } = resolutionFrame(input); + expect(role.strongRecommendation).toBe(true); + context.first.act = confidentChoice("none"); + expect(runResolution(context, entry, role)?.proposed?.type).toBe("settle.suggested"); +}); +test.each(["missing", "decided", "discarded"])("resolution target %s is handled", status => { + let input = resolutionInput(); + if (status === "missing") input.candidates[0]!.answers.thread = confidentChoice("missing"); + if (status === "decided") input.state = decidedInput().state; + if (status === "discarded") { + input.state = applyEvent(input.state, { + id: "discard", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Jules" }, + at: 1000, + }); + } + if (status !== "discarded") { + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle target needs review"); + } else { + let { context, entry, role } = resolutionFrame(); + // Synthetic frame control: original role capture skips an already discarded target. + context.working = input.state; + role.thread = input.state.threads[0]!; + expect(replay(input.state.events)).toEqual(input.state); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle target needs review"); + } +}); +test.each(["We have decided not to use Alpha.", "Let's use Alpha without backups."])( + "negative chosen source is handled: %s", + quote => { + let input = resolutionInput(); + input.message.text = quote; + Object.assign(input.candidates[0]!, { quote, end: quote.length }); + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe("chosen option needs review"); + }, +); +test.each([{ beta: false, withdrawn: false }, { beta: true, withdrawn: false }, { + beta: false, + withdrawn: true, +}])("current pending choice $beta/$withdrawn", ({ beta, withdrawn }) => { + let input = resolutionInput(beta); + pendingChoice(input, withdrawn); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let result = runResolution(context, entry, role); + if (withdrawn) expect(result?.proposed?.type).toBe("settle.suggested"); + else { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe(beta ? "review" : "ignored"); + expect(entry.outcome.gate).toBe( + beta ? "competing choice needs review" : "settle already pending", + ); + } +}); +test("current competing-target set blocks an otherwise valid choice", () => { + let { context, entry, role } = resolutionFrame(); + context.candidateRun!.competingMessageTargets.add("provider"); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("competing choice needs review"); +}); +test("fresh chosen answer materializes despite the earlier captured chosen ID", () => { + let { context, entry, role } = resolutionFrame(); + expect(role.chosenOption).toBe("alpha"); + entry.candidate.answers.chosen_option = confidentChoice("beta"); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + expect(context.working.threads[0]!.pendingSettle?.optionId).toBe("beta"); +}); +test.each(["removed", "restored"])("fresh card lookup overrides captured card: %s", variant => { + let input = resolutionInput(true), card = input.linkedCards!.get("provider")!; + if (variant === "restored") { + input.linkedCards = new Map([["provider", { ...card, cardId: "stale" }]]); + } + let { context, entry, role } = resolutionFrame(input); + let captured = role.linkedCard; + context.input.linkedCards = variant === "removed" ? new Map() : new Map([["provider", card]]); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(role.linkedCard).toBe(captured); + if (variant === "removed") { + expect(entry.outcome.gate).toBe("chosen option needs review"); + expect(context.events).toHaveLength(0); + } else expect(context.events).toHaveLength(2); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.ts new file mode 100644 index 00000000..70bb8699 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.ts @@ -0,0 +1,107 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { applyInference } from "./domain"; +import { directResolution } from "./policy-cues"; +import { choice, noul } from "./policy-scoring"; +import { effectivePending } from "./preference"; + +/** Invoke only for matching captured resolution; a result progresses without ordinary application. */ +export function runResolution( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { input, message, first, events } = context; + let { thread, strongRecommendation } = frame; + let { competingMessageTargets } = context.candidateRun!; + let { candidate, outcome } = entry; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + try { + let marker = noul(first, "explicit_resolution"); + let follow = noul(candidate.answers, "explicit_resolution"); + let credible = message.author.kind === "member" + && ((directResolution(candidate.quote) + && (marker >= 0.8 && follow >= 0.85 || choice(first, "act") === "commitment")) + || strongRecommendation); + if (!credible) { + outcome.status = "review"; + outcome.gate = "settle authority unclear"; + return undefined; + } + if (!thread || thread.status === "decided" || thread.status === "discarded") { + outcome.status = "review"; + outcome.gate = "settle target needs review"; + return undefined; + } + let chosen = choice(candidate.answers, "chosen_option"); + let card = input.linkedCards?.get(thread.id); + let cardOption = card && card.cardId === thread.questionnaireId + ? card.options.find(item => item.id === chosen) + : undefined; + let chosenOption = chosen + && thread.contributions.some((item) => item.id === chosen && item.kind === "option") + ? chosen + : cardOption?.id; + if (!chosenOption || /\b(?:not|don't|do not|without)\b/i.test(candidate.quote)) { + outcome.status = "review"; + outcome.gate = "chosen option needs review"; + return undefined; + } + let pending = effectivePending(thread, working.events); + if (pending || competingMessageTargets.has(thread.id)) { + let sameOption = pending?.optionId === chosenOption + && !competingMessageTargets.has(thread.id); + outcome.status = sameOption ? "ignored" : "review"; + outcome.gate = sameOption ? "settle already pending" : "competing choice needs review"; + return undefined; + } + if (cardOption && !thread.contributions.some(item => item.id === cardOption.id)) { + let added: Event = { + ...base(thread.id, "option.added"), + type: "option.added", + source: source("option"), + contribution: { + id: cardOption.id, + text: cardOption.label, + authoring: "scribe", + targetId: thread.id, + }, + }; + try { + let staged = applyInference(working, added, message); + let stagedThread = staged.threads.find(item => item.id === thread.id)!; + let suggested: Event = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + observedThreadVersion: stagedThread.version, + source: source("resolution"), + optionId: cardOption.id, + }; + working = applyInference(staged, suggested, message); + events.push(added, suggested); + outcome.eventIds.push(added.id, suggested.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "linked card choice could not be suggested"; + } + return undefined; + } + proposed = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + source: source("resolution"), + optionId: chosenOption, + }; + + return { proposed }; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-role.test.ts b/apps/server/src/conversation-plan/policy-candidate-role.test.ts new file mode 100644 index 00000000..acd75fee --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-role.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from "bun:test"; +import { beginCandidate } from "./policy-candidate-entry"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { groupInput } from "./policy-candidate.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; + +function capture(input = roleInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + return { context, entry, frame: captureCandidateRole(context, entry) }; +} + +test("captures target, linked card and pending references once for later stages", () => { + let input = roleInput(); + let thread = input.state.threads[0]!; + thread.pendingSettle = { optionId: "alpha", proposer: "Jules", messageId: "proposal" }; + thread.questionnaireId = "card"; + let card = { cardId: "card", options: [{ id: "alpha", label: "Alpha" }] }; + input.linkedCards = new Map([["provider", card]]); + let { context, frame } = capture(input); + expect(frame!.target).toBe("provider"); + expect(frame!.option).toBe("alpha"); + expect(frame!.pending).toBe(thread.pendingSettle); + expect(frame!.targetThread).toBe(thread); + expect(frame!.thread).toBe(thread); + expect(frame!.linkedCard).toBe(card); + context.working = { ...context.working, threads: [] }; + input.linkedCards = new Map(); + expect(frame!.targetThread).toBe(thread); + expect(frame!.pending).toBe(thread.pendingSettle); + expect(frame!.linkedCard).toBe(card); + expect(context.selectedTarget).toBe("provider"); +}); + +test("a split reason/support judgment becomes a reason only with corroborated evidence", () => { + let input = roleInput("reason", "This saves time because it reduces latency."); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "reason", + confidence: 0.9, + probabilities: { reason: 0.45, support: 0.44, none: 0.11 }, + }; + expect(capture(input).frame!.role).toBe("reason"); + input.first.reason = { type: "noul", noul: 0.79 }; + expect(capture(input).frame).toBeUndefined(); +}); + +test("agreement corroboration uses pending proposer and option before role selection", () => { + let input = roleInput("support", "Agreed."); + input.state.threads[0]!.pendingSettle = { + optionId: "alpha", + proposer: "Jules", + messageId: "proposal", + }; + input.candidates[0]!.answers.support = { type: "noul", noul: 0.1 }; + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.7 }; + expect(capture(input).frame!.role).toBe("support"); + input.state.threads[0]!.pendingSettle!.proposer = "Mina"; + expect(capture(input).frame).toBeUndefined(); +}); + +test("qualified pending evidence retains its pending choice and changes the role to constraint", () => { + let input = roleInput("reason", "Verify latency before choosing."); + let thread = input.state.threads[0]!; + thread.pendingSettle = { optionId: "alpha", proposer: "Jules", messageId: "proposal" }; + input.candidates[0]!.answers.relation = confidentChoice("qualifies"); + input.candidates[0]!.answers.qualifies_pending_settle = { type: "noul", noul: 0.8 }; + let { frame } = capture(input); + expect(frame!.role).toBe("constraint"); + expect(frame!.qualifiedPending).toBe(true); + expect(frame!.pending).toBe(thread.pendingSettle); +}); + +test("a strong owned recommendation changes an option role to resolution", () => { + let input = roleInput("option", "We should use Alpha."); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { frame } = capture(input); + expect(frame!.role).toBe("resolution"); + expect(frame!.strongRecommendation).toBe(true); +}); + +test("an owned known-option commitment can corroborate a below-choice-threshold resolution", () => { + let input = roleInput("resolution", "Let's use Alpha."); + input.first.act = confidentChoice("commitment"); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.95, + probabilities: { resolution: 0.7, none: 0.3 }, + }; + expect(capture(input).frame!.role).toBe("resolution"); +}); + +test("a mixed proposed new choice remains an option before direct commitment evidence", () => { + let input = roleInput("option", "A new provider."); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.9, + probabilities: { resolution: 0.45, option: 0.45, none: 0.1 }, + }; + let { frame } = capture(input); + expect(frame!.role).toBe("option"); + expect(frame!.directNewChoice).toBe(false); +}); + +test("a direct novel recommendation is captured as a resolution without applying its option", () => { + let input = roleInput("option", "We should use Novel."); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.option = confidentChoice("new"); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { context, frame } = capture(input); + expect(frame!.role).toBe("resolution"); + expect(frame!.directNewChoice).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test.each([0.79, 0.8])( + "withdrawal cue checks owned proposal before role override: %s", + strength => { + let input = roleInput("reason", "I withdraw that choice."); + input.first.withdrawal = { type: "noul", noul: strength }; + let { context, frame } = capture(input); + if (strength < 0.8) { + expect(frame).toBeUndefined(); + expect(context.outcomes[0]!.status).toBe("review"); + expect(context.outcomes[0]!.gate).toBe("pending withdrawal needs a clear owned proposal"); + } else expect(frame!.role).toBe("withdrawal"); + }, +); + +test("group retargeting preserves references captured before the target is overridden", () => { + let context = candidateContext(); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + let frame = captureCandidateRole(context, entry)!; + expect(frame.target).toBe(context.candidateRun!.optionGroup!); + expect(frame.thread).toBe(context.working.threads[0]!); + expect(frame.targetThread).toBeUndefined(); + expect(frame.pending).toBeUndefined(); + expect(frame.linkedCard).toBeUndefined(); +}); + +test("seen labels remain consumed after later role rejection", () => { + let context = candidateContext(); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + entry.candidate.answers.role = { + type: "choice", + choice: "reason", + confidence: 0.1, + probabilities: { reason: 0.4, none: 0.6 }, + }; + entry.candidate.answers.planning_substance = { type: "noul", noul: 0 }; + expect(captureCandidateRole(context, entry)).toBeUndefined(); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); + expect(context.events).toHaveLength(1); +}); + +test("role none progresses while preserving the prior selected target", () => { + let input = roleInput("none"); + let context = candidateContext(input); + context.selectedTarget = "prior"; + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + expect(captureCandidateRole(context, entry)!.role).toBe("none"); + expect(context.selectedTarget).toBe("prior"); +}); + +test("Planner stance rejection precedes selected-target mutation", () => { + let input = roleInput("support", "Agreed."); + input.message.author = { kind: "agent" }; + let { context, frame } = capture(input); + expect(frame).toBeUndefined(); + expect(context.selectedTarget).toBeUndefined(); + expect(context.outcomes[0]!.gate).toBe("Planner cannot cast a human stance or decision"); +}); + +test("selected target and outcome target survive the later discarded-thread skip", () => { + let input = roleInput(); + input.state.threads[0]!.status = "discarded"; + let { context, frame } = capture(input); + expect(frame).toBeUndefined(); + expect(context.selectedTarget).toBe("provider"); + expect(context.outcomes[0]!.targetId).toBe("provider"); + expect(context.outcomes[0]!.gate).toBe("thread discarded"); +}); + +test("the sequential first question becomes an option only after group retargeting", () => { + let input = groupInput(["Should we host locally?", "Or use a gateway?", "Or use keys?"]); + input.candidates[0]!.answers.role = confidentChoice("question"); + let { context, frame } = capture(input); + expect(context.candidateRun!.sequentialChoiceQuestions).toBe(true); + expect(frame!.role).toBe("option"); + expect(frame!.target).toBe(context.candidateRun!.optionGroup!); + expect(frame!.targetThread).toBeUndefined(); +}); + +test("a question role can progress on a discarded thread", () => { + let input = roleInput("question"); + input.state.threads[0]!.status = "discarded"; + let { context, frame } = capture(input); + expect(frame!.role).toBe("question"); + expect(frame!.thread!.status).toBe("discarded"); + expect(context.selectedTarget).toBe("provider"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-role.ts b/apps/server/src/conversation-plan/policy-candidate-role.ts new file mode 100644 index 00000000..6ea6f8d3 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-role.ts @@ -0,0 +1,191 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { PolicyContext } from "./policy-context"; +import { directOwnedCommitment, directRecommendation } from "./policy-cues"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import { effectivePending } from "./preference"; +import { hasWithdrawalCue } from "./question-shared"; + +/** Undefined skips this candidate; returned references keep their original pre-retarget timing. */ +export function captureCandidateRole(context: PolicyContext, entry: CandidateEntry) { + let { input, message, first } = context; + let { index, candidate, outcome } = entry; + let { optionGroup, sequentialChoiceQuestions } = context.candidateRun!; + let working = context.working; + let selectedTarget = context.selectedTarget; + try { + let target = choice(candidate.answers, "thread"); + let option = choice(candidate.answers, "option"); + let pendingThread = working.threads.find(item => item.id === target); + let pending = pendingThread && effectivePending(pendingThread, working.events); + let pendingAgreement = pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!option || option === "none" || option === pending.optionId) + && noul(candidate.answers, "agrees_with_settle") >= 0.7; + let role = roleChoice(candidate.answers, first, Boolean(pendingAgreement)); + let roleAnswer = candidate.answers.role; + let targetThread = working.threads.find((item) => item.id === target); + let chosenOption = choice(candidate.answers, "chosen_option"); + let linkedCard = targetThread && input.linkedCards?.get(targetThread.id); + let knownChosenOption = !!targetThread && !!chosenOption + && (targetThread.contributions.some(item => + item.kind === "option" && item.id === chosenOption + ) || !!linkedCard && linkedCard.cardId === targetThread.questionnaireId + && linkedCard.options.some(item => item.id === chosenOption)); + let reasonProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.reason ?? 0 + : 0; + let supportProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.support ?? 0 + : 0; + if ( + !role && roleAnswer?.type === "choice" + && ["reason", "support"].includes(roleAnswer.choice) + && reasonProbability >= 0.35 && supportProbability >= 0.3 + && reasonProbability + supportProbability >= 0.8 + && Math.abs(reasonProbability - supportProbability) <= 0.12 + && targetThread && !["decided", "discarded"].includes(targetThread.status) + && choice(first, "thread_target") === targetThread.id + && option && targetThread.contributions.some(item => + item.kind === "option" && item.id === option + ) + && ["supports", "challenges", "qualifies"].includes( + choice(candidate.answers, "relation") ?? "", + ) + && noul(first, "reason") >= 0.8 + && first.significance?.type === "score" && first.significance.score >= 1 + && noul(candidate.answers, "planning_substance") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && /\b(?:because|since|reduces?|avoids?|saves?|improves?|allows?|enables?|so that|so we can|lets? us)\b/i + .test(candidate.quote) + ) role = "reason"; + let relationAnswer = candidate.answers.relation; + let qualifiedPending = pending && targetThread + && targetThread.status !== "decided" && targetThread.status !== "discarded" + && targetThread.contributions.some(item => + item.kind === "option" && item.id === pending.optionId + ) + && relationAnswer?.type === "choice" && relationAnswer.choice === "qualifies" + && (relationAnswer.probabilities.qualifies ?? 0) >= 0.7 + && (relationAnswer.probabilities.qualifies ?? 0) + - Math.max( + 0, + ...Object.entries(relationAnswer.probabilities) + .filter(([key]) => key !== "qualifies").map(([, value]) => value), + ) >= 0.2 + && noul(candidate.answers, "qualifies_pending_settle") >= 0.8 + && noul(candidate.answers, "planning_substance") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && first.significance?.type === "score" && first.significance.score >= 1; + if ( + qualifiedPending && (!role || ["option", "reason", "constraint", "support"].includes(role)) + ) { + role = "constraint"; + } + let strongRecommendation = message.author.kind === "member" + && directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8 + && !!targetThread && !["decided", "discarded"].includes(targetThread.status) + && (!option || option === "none" || option === chosenOption) + && knownChosenOption; + let resolutionProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.resolution ?? 0 + : 0; + let optionProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.option ?? 0 + : 0; + if ( + !role && message.author.kind === "member" && targetThread + && !["decided", "discarded"].includes(targetThread.status) + && directOwnedCommitment(candidate.quote) + && choice(first, "act") === "commitment" + && resolutionProbability >= 0.7 + && knownChosenOption + ) role = "resolution"; + // A proposed new choice can sound like a resolution; show it without settling it. + if ( + !role && targetThread && !["decided", "discarded"].includes(targetThread.status) + && choice(candidate.answers, "chosen_option") === "new" + && resolutionProbability >= 0.3 && optionProbability >= 0.3 + && resolutionProbability + optionProbability >= 0.85 + && Math.abs(resolutionProbability - optionProbability) <= 0.15 + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.8 + && first.significance?.type === "score" && first.significance.score >= 2 + ) role = "option"; + if (strongRecommendation && (!role || role === "option" || role === "support")) { + role = "resolution"; + } + let directNewChoice = message.author.kind === "member" && !!targetThread + && !["decided", "discarded"].includes(targetThread.status) + && chosenOption === "new" && option === "new" + && (!role || ["option", "support", "resolution"].includes(role)) + && candidate.quote.length <= 500 && !candidate.quote.trim().endsWith("?") + && !/\b(?:not|don't|do not|without|if|unless)\b/i.test(candidate.quote) + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.3 + && !targetThread.contributions.some(item => + item.kind === "option" + && item.text.trim().replace(/\s+/g, " ").toLowerCase() + === candidate.quote.trim().replace(/\s+/g, " ").toLowerCase() + ) + && (directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8 + || directOwnedCommitment(candidate.quote) + && choice(first, "act") === "commitment" + && noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.8); + if (directNewChoice) role = "resolution"; + if (hasWithdrawalCue(candidate.quote)) { + if (noul(first, "withdrawal") < 0.8) { + outcome.status = "review"; + outcome.gate = "pending withdrawal needs a clear owned proposal"; + return undefined; + } + role = "withdrawal"; + } + if (optionGroup) target = optionGroup; + if (optionGroup && sequentialChoiceQuestions && index === 0 && role === "question") { + role = "option"; + } + if (!role) return undefined; + if ( + message.author.kind === "agent" + && ["support", "objection", "withdrawal", "resolution", "reopening"].includes(role) + ) { + outcome.gate = "Planner cannot cast a human stance or decision"; + return undefined; + } + if (role !== "none" && target && !["new", "none"].includes(target)) { + selectedTarget = target; + outcome.targetId = target; + } + let thread = working.threads.find((item) => item.id === target); + if (thread?.status === "discarded" && role !== "question") { + outcome.gate = "thread discarded"; + return undefined; + } + return { + target, + option, + pending, + role, + targetThread, + chosenOption, + linkedCard, + qualifiedPending, + strongRecommendation, + directNewChoice, + thread, + }; + } finally { + context.selectedTarget = selectedTarget; + } +} + +export type CandidateRole = NonNullable>; diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts new file mode 100644 index 00000000..b0632ed4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts @@ -0,0 +1,119 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { Event, PolicyInput } from "./policy-types"; + +// Pure handler fixtures for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements1160–1295. Full downstream callbacks remain assigned. +export let cardId = "01M3QAQ8HM8DVYA9E4N28QCQ7T"; +export function scopedInput(assent = false): PolicyInput { + let input = roleInput( + "support", + assent ? "yep, Alpha for a spike." : "I'd pick Alpha for a spike;", + ); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.state = applyEvent(input.state, { + id: "card-link", + type: "card.linked", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }, { id: "beta", label: "Beta" }], + }]]); + input.first.support = { type: "noul", noul: 0.95 }; + if (assent) { + let saved = message("scoped-proposal-message", "I'd pick Alpha for a spike;", "Jules"); + input.state = applyInference(input.state, { + id: "scoped-proposal", + type: "scoped-choice.proposed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "support", + }, + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }, saved); + } + return input; +} + +export function newChoiceInput(): PolicyInput { + let input = roleInput("option", "We should use Novel."); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.candidates[0]!.answers.option = confidentChoice("new"); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.95 }; + return input; +} + +export function pendingChoice(input: PolicyInput, withdrawn = false): void { + let saved = message("settle-message", "Let's use Alpha.", "Jules"); + let base = (id: string) => ({ + id, + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + }); + let citation = ( + role: "resolution" | "withdrawal", + ): Extract["source"] => ({ + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role, + }); + input.state = applyInference(input.state, { + ...base("settle"), + type: "settle.suggested", + source: citation("resolution"), + optionId: "alpha", + }, saved); + if (withdrawn) { + saved = message("withdraw-message", "I withdraw that choice.", "Jules"); + input.state = applyInference(input.state, { + ...base("withdraw"), + type: "stance.changed", + source: citation("withdrawal"), + optionId: "alpha", + scopedProposalId: null, + position: "neutral", + }, saved); + } +} + +export function scopedFrame(input = scopedInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + let spike = runCandidateVerification(context, entry, role); + expect(spike).toBeDefined(); + if (!spike) throw new Error("fixture handled before scoped handlers"); + return { context, entry, role, spike }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts new file mode 100644 index 00000000..90ca0797 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { runScopedAssent, runScopedProposal } from "./policy-candidate-scoped"; +import { cardId, scopedFrame, scopedInput } from "./policy-candidate-scoped.test-fixtures"; + +test("a scoped proposal uses the linked card and cached spike label without choosing globally", () => { + let input = scopedInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role, spike } = scopedFrame(input); + let before = structuredClone(input); + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(context.events[0]!).toMatchObject({ + type: "scoped-choice.proposed", + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }); + expect(context.working.threads[0]!.pendingScopedChoice!.proposer).toBe("Mina"); + expect(context.working.threads[0]!.pendingSettle).toBeUndefined(); + expect(entry.outcome.status).toBe("accepted"); + expect(input).toEqual(before); + expect(replay(context.working.events)).toEqual(context.working); +}); + +test("another member independently agrees with the exact scoped proposal", () => { + let input = scopedInput(true); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + expect(runScopedAssent(context, entry, role)).toBeUndefined(); + expect(context.events[0]!).toMatchObject({ + type: "scoped-choice.agreed", + proposalId: "scoped-proposal", + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }); + expect(entry.outcome.eventIds).toEqual([context.events[0]!.id]); + expect(context.working.threads[0]!.pendingSettle).toBeUndefined(); + expect(replay(context.working.events)).toEqual(context.working); +}); + +test.each(["proposer", "wrong card", "duplicate label", "weak option", "weak chosen"])( + "assent %s falls through", + kind => { + let input = scopedInput(true); + if (kind === "proposer") input.message.author = { kind: "member", handle: "Jules" }; + if (kind === "wrong card") input.linkedCards!.get("provider")!.cardId = "other-card"; + if (kind === "duplicate label") { + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }, { id: "alias", label: "Alpha" }], + }]]); + } + if (kind === "weak option") { + input.candidates[0]!.answers.option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.49, none: 0.51 }, + }; + } + if (kind === "weak chosen") { + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.59, none: 0.41 }, + }; + } + let { context, entry, role } = scopedFrame(input); + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(context.events).toHaveLength(0); + }, +); + +test("assent accepts its raw option/chosen probabilities at their original bounds", () => { + let input = scopedInput(true); + input.candidates[0]!.answers.option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.5, none: 0.5 }, + }; + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.6, none: 0.4 }, + }; + let { context, entry, role } = scopedFrame(input); + expect(role.option).toBeUndefined(); + expect(role.chosenOption).toBeUndefined(); + expect(runScopedAssent(context, entry, role)).toBeUndefined(); +}); + +test("missing proposal lineage falls through even with a captured pending choice", () => { + let { context, entry, role } = scopedFrame(scopedInput(true)); + context.working = { + ...context.working, + events: context.working.events.filter(event => event.id !== "scoped-proposal"), + }; + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test("the duplicate proposal check retains the captured pending option and ignored status", () => { + let input = scopedInput(true); + input.message.text = "I'd pick Alpha for a spike;"; + input.candidates[0]!.quote = input.message.text; + input.candidates[0]!.end = input.message.text.length; + let { context, entry, role, spike } = scopedFrame(input); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(entry.outcome.gate).toBe("spike choice already pending"); + expect(entry.outcome.status).toBe("ignored"); + expect(context.events).toHaveLength(0); +}); + +test("proposal uses the cached label rather than reparsing the valid quote", () => { + let { context, entry, role, spike } = scopedFrame(); + spike.spikeLabel = "Beta"; + expect(runScopedProposal(context, entry, role, spike)).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test("a replacement input card map does not replace the earlier captured linked card", () => { + let { context, entry, role, spike } = scopedFrame(); + let captured = role.linkedCard; + context.input.linkedCards = new Map([["provider", { cardId: "replacement", options: [] }]]); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(role.linkedCard).toBe(captured); + expect(context.events[0]!).toMatchObject({ type: "scoped-choice.proposed", cardId }); +}); + +test.each(["absent", "ambiguous"])("proposal requires one named linked-card label: %s", kind => { + let input = scopedInput(); + input.linkedCards = new Map([["provider", { + cardId, + options: kind === "absent" + ? [{ id: "beta", label: "Beta" }] + : [{ id: "alpha", label: "Alpha" }, { id: "alias", label: "Alpha" }], + }]]); + let { context, entry, role, spike } = scopedFrame(input); + expect(runScopedProposal(context, entry, role, spike)).toBe(true); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.ts new file mode 100644 index 00000000..6b0e9608 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.ts @@ -0,0 +1,141 @@ +import { applyInference } from "./domain"; +import { currentScopedProposal } from "./events"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { runCandidateVerification } from "./policy-candidate-verification"; +import type { PolicyContext } from "./policy-context"; +import { immediateSpikeCondition, namesCardOption } from "./policy-cues"; +import { choice, moderateChoice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { spikeAgreementLabel } from "./validation-fields"; + +type Spike = NonNullable>; + +/** Run after verification progresses. Undefined handles/skips; true advances to scoped proposal. */ +export function runScopedAssent( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): true | undefined { + let { message, first, events } = context; + let { candidate, outcome } = entry; + let { targetThread, linkedCard, role } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + let pendingScoped = targetThread?.pendingScopedChoice; + let assentLabel = spikeAgreementLabel(candidate.quote); + let optionAnswer = candidate.answers.option; + let chosenAnswer = candidate.answers.chosen_option; + if ( + role === "support" && assentLabel && candidate.start === 0 + && !immediateSpikeCondition(message.text, candidate.end) + && message.author.kind === "member" && targetThread && pendingScoped + && !["decided", "discarded"].includes(targetThread.status) + && message.author.handle !== pendingScoped.proposer + && choice(first, "thread_target") === targetThread.id + && noul(first, "support") >= 0.8 && noul(candidate.answers, "support") >= 0.8 + && optionAnswer?.type === "choice" && optionAnswer.choice === pendingScoped.optionId + && (optionAnswer.probabilities[pendingScoped.optionId] ?? 0) >= 0.5 + && chosenAnswer?.type === "choice" && chosenAnswer.choice === pendingScoped.optionId + && (chosenAnswer.probabilities[pendingScoped.optionId] ?? 0) >= 0.6 + && pendingScoped.scope === "spike" + && assentLabel.toLocaleLowerCase() === pendingScoped.label.toLocaleLowerCase() + && linkedCard && linkedCard.cardId === targetThread.questionnaireId + && linkedCard.cardId === pendingScoped.cardId + && linkedCard.options.filter(item => + item.id === pendingScoped.optionId && item.label === pendingScoped.label + ).length === 1 + && linkedCard.options.filter(item => namesCardOption(candidate.quote, item.label)).length + === 1 + ) { + let proposal = currentScopedProposal(targetThread, working.events); + if (proposal) { + let agreed: Event = { + ...base(targetThread.id, "scoped-choice.agreed"), + type: "scoped-choice.agreed", + source: source("support"), + proposalId: proposal.id, + cardId: pendingScoped.cardId, + optionId: pendingScoped.optionId, + label: pendingScoped.label, + scope: "spike", + }; + try { + working = applyInference(working, agreed, message); + events.push(agreed); + outcome.eventIds.push(agreed.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "scoped agreement needs review"; + } + return undefined; + } + } + return true; + } finally { + context.working = working; + } +} + +/** Requires assent to progress; the spike label is the earlier verification capture. */ +export function runScopedProposal( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, + spike: Spike, +): true | undefined { + let { message, first, events } = context; + let { candidate, outcome } = entry; + let { targetThread, linkedCard, role, option, chosenOption } = frame; + let { spikeLabel } = spike; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + if ( + (role === "support" || role === "option") && spikeLabel + && message.author.kind === "member" && targetThread + && !["decided", "discarded"].includes(targetThread.status) + && moderateChoice(first, "act", "proposal", 0.7) + && choice(first, "thread_target") === targetThread.id + && option && option === chosenOption && !["new", "none"].includes(option) + && noul(candidate.answers, "support") >= 0.8 + && linkedCard && linkedCard.cardId === targetThread.questionnaireId + ) { + let named = linkedCard.options.filter(item => namesCardOption(candidate.quote, item.label)); + let selected = named.length === 1 && named[0]?.id === option ? named[0] : undefined; + if (selected && selected.label.toLocaleLowerCase() === spikeLabel.toLocaleLowerCase()) { + if (targetThread.pendingScopedChoice?.optionId === option) { + outcome.gate = "spike choice already pending"; + return undefined; + } + let scoped: Event = { + ...base(targetThread.id, "scoped-choice.proposed"), + type: "scoped-choice.proposed", + source: source("support"), + cardId: linkedCard.cardId, + optionId: selected.id, + label: selected.label, + scope: "spike", + }; + try { + working = applyInference(working, scoped, message); + events.push(scoped); + outcome.eventIds.push(scoped.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "scoped choice needs review"; + } + return undefined; + } + } + return true; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-setup.test.ts b/apps/server/src/conversation-plan/policy-candidate-setup.test.ts new file mode 100644 index 00000000..e627eaa9 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-setup.test.ts @@ -0,0 +1,240 @@ +import { expect, test } from "bun:test"; +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { choiceInput, groupInput, prepareCandidateContext } from "./policy-candidate.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { stableId } from "./policy-identity"; + +function setup(input = groupInput()) { + return prepareCandidateRun(prepareCandidateContext(input)); +} + +test("groups bounded exact option spans with the original deterministic identity", () => { + let input = groupInput(); + let before = structuredClone(input); + let context = prepareCandidateContext(input); + let run = prepareCandidateRun(context); + expect(run.optionGroup).toBe( + `thread:${stableId("channel", input.message.id, 0, "thread").slice(11)}`, + ); + expect(run.sequentialChoiceQuestions).toBe(false); + expect([...run.seenOptionLabels]).toEqual([]); + expect([...run.competingMessageTargets]).toEqual([]); + expect(context.candidateRun).toBe(run); + expect(input).toEqual(before); +}); + +test("retains sequential question classification and its first question-role exception", () => { + let input = groupInput(["Should we host locally?", "Or use a gateway?", "Or use keys?"]); + input.candidates[0]!.answers.role = confidentChoice("question"); + let run = setup(input); + expect(run.sequentialChoiceQuestions).toBe(true); + expect(run.optionGroup).toBeDefined(); +}); + +test.each([ + "ownership", + "span", + "new option", + "role", + "comma alternatives", + "thread choice", + "duplicate labels", +])("does not create an option group for invalid %s", kind => { + let input = groupInput(); + let candidate = input.candidates[1]!; + if (kind === "ownership") input.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + if (kind === "span") candidate.start += 1; + if (kind === "new option") candidate.answers.new_option = { type: "noul", noul: 0.74 }; + if (kind === "role") candidate.answers.role = confidentChoice("reason"); + if (kind === "comma alternatives") input = groupInput(["Alpha, Beta or Gamma", "Delta"]); + if (kind === "thread choice") candidate.answers.thread = confidentChoice("provider"); + if (kind === "duplicate labels") input = groupInput(["Alpha", "alpha"]); + expect(setup(input).optionGroup).toBeUndefined(); +}); + +test("uses raw new/none thread choice for grouping despite low choice probabilities", () => { + let input = groupInput(); + input.candidates[0]!.answers.thread = { + type: "choice", + choice: "new", + confidence: 0.1, + probabilities: { new: 0.1, none: 0.9 }, + }; + expect(setup(input).optionGroup).toBeDefined(); +}); + +test.each([ + { ids: ["alpha", "alpha"], expected: [] }, + { ids: ["alpha", "beta"], expected: ["provider"] }, +])("counts distinct known option IDs: $ids", ({ ids, expected }) => { + expect([...setup(choiceInput(ids)).competingMessageTargets]).toEqual([...expected]); +}); + +test("accepts linked choices only from the thread's matching card", () => { + let input = choiceInput(["linked-a", "linked-b"]); + input.state.threads[0]!.questionnaireId = "card"; + input.linkedCards = new Map([["provider", { + cardId: "card", + options: [{ id: "linked-a", label: "A" }, { id: "linked-b", label: "B" }], + }]]); + expect([...setup(input).competingMessageTargets]).toEqual(["provider"]); + input.linkedCards.get("provider")!.cardId = "wrong-card"; + expect([...setup(input).competingMessageTargets]).toEqual([]); +}); + +test.each([ + { quotes: ["We should use Alpha.", "We should use alpha."], expected: [] }, + { quotes: ["We should use Alpha.", "We should use Beta."], expected: ["provider"] }, +])("normalizes novel recommendations for competition: $quotes", ({ quotes, expected }) => { + let input = choiceInput(["new", "new"]); + let spans = groupInput(quotes); + input.message = spans.message; + input.candidates.forEach((candidate, index) => + Object.assign(candidate, { + start: spans.candidates[index]!.start, + end: spans.candidates[index]!.end, + quote: quotes[index], + }) + ); + expect([...setup(input).competingMessageTargets]).toEqual([...expected]); +}); + +test.each(["agent", "decided", "discarded", "unowned", "wrong quote", "weak support"])( + "excludes competing choices for %s", + kind => { + let input = choiceInput(); + if (kind === "agent") input.message.author = { kind: "agent" }; + if (kind === "decided" || kind === "discarded") input.state.threads[0]!.status = kind; + if (kind === "unowned") input.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + if (kind === "wrong quote") input.candidates[1]!.start += 1; + if (kind === "weak support") { + input.candidates[1]!.answers.support = { type: "noul", noul: 0.79 }; + } + expect([...setup(input).competingMessageTargets]).toEqual([]); + }, +); + +test("uses original input state and captured message rather than changed working/input", () => { + let input = choiceInput(); + let context = prepareCandidateContext(input); + context.working = { ...input.state, threads: [] }; + input.message = { ...input.message, text: "changed", author: { kind: "agent" } }; + expect([...prepareCandidateRun(context).competingMessageTargets]).toEqual(["provider"]); +}); + +test("uses cached option labels despite otherwise valid distinct candidate spans", () => { + let context = prepareCandidateContext(groupInput()); + context.facts!.optionLabels = ["alpha", "alpha", "alpha"]; + expect(prepareCandidateRun(context).optionGroup).toBeUndefined(); +}); + +test("owns fresh mutable sets and group state for each candidate run", () => { + let a = setup(); + let b = setup(); + expect(a.seenOptionLabels).not.toBe(b.seenOptionLabels); + expect(a.competingMessageTargets).not.toBe(b.competingMessageTargets); + a.seenOptionLabels.add("alpha"); + a.competingMessageTargets.add("provider"); + a.optionGroup = undefined; + expect([...b.seenOptionLabels]).toEqual([]); + expect([...b.competingMessageTargets]).toEqual([]); + expect(b.optionGroup).toBeDefined(); +}); + +test("preserves competing-thread insertion order across four candidates", () => { + let input = choiceInput(["alpha", "beta", "alpha", "beta"]); + let provider = input.state.threads[0]!; + let other = input.state.threads[1]!; + other.contributions.push({ ...provider.contributions[0]! }); + input.candidates[0]!.answers.thread = confidentChoice("other"); + input.candidates[1]!.answers.thread = confidentChoice("other"); + expect([...setup(input).competingMessageTargets]).toEqual(["other", "provider"]); +}); + +test.each([ + { + novel: false, + linked: false, + act: "commitment", + resolution: 0, + candidateResolution: 0, + expected: ["provider"], + }, + { + novel: false, + linked: true, + act: "commitment", + resolution: 0, + candidateResolution: 0, + expected: ["provider"], + }, + { + novel: false, + linked: false, + act: "proposal", + resolution: 0.8, + candidateResolution: 0.84, + expected: [], + }, + { + novel: false, + linked: false, + act: "proposal", + resolution: 0.8, + candidateResolution: 0.85, + expected: ["provider"], + }, + { + novel: true, + linked: false, + act: "commitment", + resolution: 0.79, + candidateResolution: 0.8, + expected: [], + }, + { + novel: true, + linked: false, + act: "commitment", + resolution: 0.8, + candidateResolution: 0.8, + expected: ["provider"], + }, + { + novel: true, + linked: false, + act: "proposal", + resolution: 1, + candidateResolution: 1, + expected: [], + }, +])( + "uses known/linked vs novel commitment evidence: $novel/$linked/$act/$resolution/$candidateResolution", + ({ novel, linked, act, resolution, candidateResolution, expected }) => { + let input = choiceInput( + novel ? ["new", "new"] : linked ? ["linked-a", "linked-b"] : ["alpha", "beta"], + ); + if (linked) { + input.state.threads[0]!.questionnaireId = "card"; + input.linkedCards = new Map([["provider", { + cardId: "card", + options: [{ id: "linked-a", label: "A" }, { id: "linked-b", label: "B" }], + }]]); + } + let spans = groupInput(["Let's use Alpha.", "Let's use Beta."]); + input.message = spans.message; + input.first.act = confidentChoice(act); + input.first.explicit_resolution = { type: "noul", noul: resolution }; + input.candidates.forEach((candidate, index) => + Object.assign(candidate, { + start: spans.candidates[index]!.start, + end: spans.candidates[index]!.end, + quote: spans.candidates[index]!.quote, + }) + ); + for (let candidate of input.candidates) { + candidate.answers.explicit_resolution = { type: "noul", noul: candidateResolution }; + } + expect([...setup(input).competingMessageTargets]).toEqual([...expected]); + }, +); diff --git a/apps/server/src/conversation-plan/policy-candidate-setup.ts b/apps/server/src/conversation-plan/policy-candidate-setup.ts new file mode 100644 index 00000000..8d50a2b7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-setup.ts @@ -0,0 +1,96 @@ +import type { CandidateRun, PolicyContext } from "./policy-context"; +import { + COMMA_ALTERNATIVES, + directOwnedCommitment, + directRecommendation, + OWNED_QUOTE_MIN, +} from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import { MAX_QUOTE_CANDIDATES } from "./quote-budget"; + +/** Prepare once after both terminal runners continue; later candidates share this mutable run. */ +export function prepareCandidateRun(context: PolicyContext): CandidateRun { + let { input, channelId, message, first } = context; + let { optionLabels } = context.facts!; + let seenOptionLabels = new Set(); + let sequentialChoiceQuestions = input.candidates.length === 3 + && input.candidates[0]?.start === 0 + && input.candidates[2]?.end === message.text.length + && /^Should we\b[^?]{1,160}\?$/i.test(input.candidates[0]?.quote ?? "") + && input.candidates.slice(1).every((candidate, index) => + /^Or\b[^?]{1,160}\?$/i.test(candidate.quote) + && /^\s+$/.test(message.text.slice(input.candidates[index]!.end, candidate.start)) + ); + let optionGroup = input.state.threads.length === 0 && message.text.length <= 500 + && noul(first, "new_question") >= 0.9 + && choice(first, "act") === "question" && choice(first, "thread_target") === "new" + && input.candidates.length >= 2 && input.candidates.length <= MAX_QUOTE_CANDIDATES + && new Set(optionLabels).size >= 2 + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && Number.isSafeInteger(candidate.start) && candidate.start >= 0 + && Number.isSafeInteger(candidate.end) && candidate.end <= message.text.length + && candidate.end > candidate.start + && candidate.quote.length <= 500 && !!candidate.quote.trim() + && message.text.slice(candidate.start, candidate.end) === candidate.quote + && (roleChoice(candidate.answers, first) === "option" + || sequentialChoiceQuestions && index === 0 + && roleChoice(candidate.answers, first) === "question") + && !COMMA_ALTERNATIVES.test(candidate.quote.trim()) + && noul(candidate.answers, "new_option") >= 0.75 + && candidate.answers.thread?.type === "choice" + && ["new", "none"].includes(candidate.answers.thread.choice) + ) + ? `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}` + : undefined; + let choicesByThread = new Map>(); + for (let [index, candidate] of input.candidates.entries()) { + let target = choice(candidate.answers, "thread"); + let chosen = choice(candidate.answers, "chosen_option"); + let namedOption = choice(candidate.answers, "option"); + let thread = input.state.threads.find(item => item.id === target); + if ( + message.author.kind !== "member" || !thread + || ["decided", "discarded"].includes(thread.status) + || noul(first, `c${index}_owned_unretracted`) < OWNED_QUOTE_MIN + || message.text.slice(candidate.start, candidate.end) !== candidate.quote + ) continue; + let known = chosen + && thread.contributions.some(item => item.kind === "option" && item.id === chosen); + let card = input.linkedCards?.get(thread.id); + let linkedOption = card && card.cardId === thread.questionnaireId + && card.options.some(item => item.id === chosen); + let novel = chosen === "new" && namedOption === "new" + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.3; + if (!known && !linkedOption && !novel) continue; + let recommendation = directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8; + let commitment = directOwnedCommitment(candidate.quote) + && (known || linkedOption + ? choice(first, "act") === "commitment" + || noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.85 + : choice(first, "act") === "commitment" + && noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.8); + if (!recommendation && !commitment) continue; + let choices = choicesByThread.get(thread.id) ?? new Set(); + choices.add( + known || linkedOption + ? chosen! + : `new:${candidate.quote.trim().replace(/\s+/g, " ").toLowerCase()}`, + ); + choicesByThread.set(thread.id, choices); + } + let competingMessageTargets = new Set( + [...choicesByThread].filter(([, choices]) => choices.size > 1).map(([id]) => id), + ); + let run = { seenOptionLabels, sequentialChoiceQuestions, optionGroup, competingMessageTargets }; + context.candidateRun = run; + return run; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts b/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts new file mode 100644 index 00000000..b8cdc93d --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts @@ -0,0 +1,92 @@ +import { activeScopedSupport, currentScopedProposal } from "./events"; +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + rawChoice, + scopedStanceInput, + stanceFrame, + withdrawScopedAgreement, +} from "./policy-candidate-stance.test-fixtures"; +import { runStance } from "./policy-candidate-stance"; + +test.each([{ participant: "Jules", agreed: false, expected: "scoped-proposal" }, { + participant: "Mina", + agreed: true, + expected: "scoped-proposal", +}, { participant: "Cora", agreed: false, expected: null }])( + "scoped objection ownership: $participant/$agreed", + ({ participant, agreed, expected }) => { + let input = scopedStanceInput(participant, agreed); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + position: "oppose", + optionId: "alpha", + scopedProposalId: expected, + }); + let accepted = applyInference(input.state, event, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.events).toHaveLength(0); + }, +); +test("support leaves scopedProposalId null even for the scoped proposer", () => { + let { context, entry, role } = stanceFrame(scopedStanceInput("Jules", false, "support")); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + position: "support", + scopedProposalId: null, + }); +}); +test.each(["proposal", "agreement"])( + "current history must retain the exact %s for scoped ownership", + removed => { + let { context, entry, role } = stanceFrame(scopedStanceInput("Mina", true)); + let captured = role.thread; + context.working = { + ...context.working, + events: context.working.events.filter(event => + removed === "proposal" ? event.id !== "scoped-proposal" : event.id !== "scoped-assent" + ), + }; + expect(runStance(context, entry, role)!.proposed).toMatchObject({ scopedProposalId: null }); + expect(role.thread).toBe(captured); + }, +); +test.each(["option", "chosen_option"])( + "scoped ownership requires confident %s despite raw fallback", + answer => { + let input = scopedStanceInput(); + input.candidates[0]!.answers[answer] = rawChoice("alpha"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + optionId: "alpha", + scopedProposalId: null, + }); + }, +); +test("restoring a fresh card map cannot replace a mismatched captured scoped card", () => { + let input = scopedStanceInput(); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { ...card, cardId: "stale" }]]); + let { context, entry, role } = stanceFrame(input); + context.input.linkedCards = new Map([["provider", card]]); + expect(role.linkedCard!.cardId).toBe("stale"); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ scopedProposalId: null }); +}); + +test("historical agreement still establishes ownership after active support withdrawal", () => { + let input = scopedStanceInput("Mina", true); + withdrawScopedAgreement(input); + expect(replay(input.state.events)).toEqual(input.state); + let proposal = currentScopedProposal(input.state.threads[0]!, input.state.events)!; + expect( + activeScopedSupport(input.state.events, proposal).some(event => + event.source.author.kind === "member" && event.source.author.handle === "Mina" + ), + ).toBe(false); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + scopedProposalId: "scoped-proposal", + }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts new file mode 100644 index 00000000..39b6694b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts @@ -0,0 +1,107 @@ +import { applyEvent } from "./events"; +import { applyInference } from "./domain"; +import { + contributionFrame, + contributionInput, + linkContributionCard, +} from "./policy-candidate-contribution.test-fixtures"; +import { scopedInput } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequent1433–1530. +// Original complete downstream callbacks remain assigned whole. +export function stanceInput( + role = "support", + quote = role === "support" ? "I support Alpha." : "I oppose Alpha.", +): PolicyInput { + let input = contributionInput(role, quote); + linkContributionCard(input); + return input; +} +export function decidedInput(probability = 0.68) { + let input = stanceInput("objection"); + input.state = applyEvent(input.state, { + id: "decide-provider", + type: "decision.recorded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Jules" }, + at: 1000, + explicit: true, + text: "Use Alpha.", + optionId: "alpha", + }); + input.candidates[0]!.answers.material_objection = { type: "noul", noul: probability }; + return input; +} +export function scopedStanceInput(participant = "Jules", agreed = false, role = "objection") { + let input = stanceInput(role), saved = scopedInput(true); + input.state = saved.state; + input.linkedCards = saved.linkedCards; + input.message.author = { kind: "member", handle: participant }; + if (agreed) { + let assent = message("scoped-assent", "yep, Alpha for a spike.", "Mina"); + input.state = applyInference(input.state, { + id: "scoped-assent", + type: "scoped-choice.agreed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: assent.id, + author: { kind: "member", handle: "Mina" }, + quote: assent.text, + start: 0, + end: assent.text.length, + role: "support", + }, + proposalId: "scoped-proposal", + cardId: input.state.threads[0]!.questionnaireId!, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }, assent); + } + return input; +} +export function rawChoice(id: string, probability = 0.4) { + return { + type: "choice" as const, + choice: id, + confidence: probability, + probabilities: { [id]: probability, none: 0.35, other: 0.25 }, + }; +} +export function stanceFrame(input = stanceInput()) { + return contributionFrame(input); +} +export { confidentChoice }; + +export function withdrawScopedAgreement(input: PolicyInput) { + let saved = message("scoped-withdrawal", "I withdraw my spike support.", "Mina"); + input.state = applyInference(input.state, { + id: "scoped-withdrawal", + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Mina" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "withdrawal", + }, + optionId: "alpha", + scopedProposalId: "scoped-proposal", + position: "neutral", + }, saved); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.test.ts b/apps/server/src/conversation-plan/policy-candidate-stance.test.ts new file mode 100644 index 00000000..7f043962 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + confidentChoice, + decidedInput, + rawChoice, + stanceFrame, + stanceInput, +} from "./policy-candidate-stance.test-fixtures"; +import { runStance } from "./policy-candidate-stance"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { linkContributionCard } from "./policy-candidate-contribution.test-fixtures"; +import { stableId } from "./policy-identity"; + +test.each(["support", "objection"])( + "%s returns the exact regular stance without applying it", + name => { + let input = stanceInput(name); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + expect(role.role).toBe(name); + let before = structuredClone(context.working); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + source: { role: name, quote: input.message.text }, + position: name === "support" ? "support" : "oppose", + optionId: "alpha", + scopedProposalId: null, + }); + let accepted = applyInference(input.state, event, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); + expect(context.reviews).toHaveLength(0); + expect(entry.outcome.eventIds).toHaveLength(0); + }, +); +test.each([0.679, 0.68])("decided material objection threshold %s", probability => { + let input = decidedInput(probability); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + if (probability === 0.68) { + expect(event).toMatchObject({ + type: "candidate.proposed", + source: { role: "reopening" }, + candidate: { + id: stableId(input.channelId, input.message.id, 0, "reopening-candidate"), + kind: "reopening", + text: input.message.text, + }, + }); + expect(context.reviews).toHaveLength(1); + } else { + expect(event.type).toBe("stance.changed"); + expect(context.reviews).toHaveLength(0); + } + expect(context.events).toHaveLength(0); +}); +test("a second reopening review is handled before constructing a proposal", () => { + let { context, entry, role } = stanceFrame(decidedInput()); + context.reviews.push({ id: "existing", kind: "reopening", targetId: "provider" }); + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening review already proposed from this message"); + expect(context.reviews).toHaveLength(1); +}); +test("missing captured thread is handled without a stance", () => { + let input = stanceInput(); + input.candidates[0]!.answers.thread = confidentChoice("missing"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("stance target or human authority unclear"); +}); +test("late agent author fails the original human guard", () => { + let { context, entry, role } = stanceFrame(); + context.message.author = { kind: "agent" }; + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); +}); +test.each(["wrong-card", "substring", "duplicate-name", "wrong-text", "wrong-target", "unnamed"])( + "captured card boundary: %s", + variant => { + let input = stanceInput(); + let card = input.linkedCards!.get("provider")!; + if (variant === "wrong-card") { + input.linkedCards = new Map([["provider", { + ...card, + cardId: "wrong", + }]]); + } + if (variant === "substring" || variant === "duplicate-name") { + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { + id: "other", + label: variant === "substring" ? "Alpha Pro" : "Alpha", + }], + }]]); + } + if (variant === "wrong-text") { + input.linkedCards = new Map([["provider", { + ...card, + options: [{ id: "alpha", label: "Beta" }], + }]]); + input.message.text = "I support Beta."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + } + if (variant === "wrong-target") input.first.thread_target = confidentChoice("other"); + if (variant === "unnamed") { + input.message.text = "I agree."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + } + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + optionId: undefined, + scopedProposalId: null, + }); + expect(Object.hasOwn(event, "optionId")).toBe(true); + }, +); +test.each(["alpha", "beta"])("raw option fallback preserves %s despite low confidence", option => { + let input = stanceInput(); + input.candidates[0]!.answers.option = rawChoice(option); + input.candidates[0]!.answers.chosen_option = rawChoice("alpha"); + let { context, entry, role } = stanceFrame(input); + expect(role.option).toBeUndefined(); + expect(role.chosenOption).toBeUndefined(); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ optionId: option === "alpha" ? "alpha" : undefined }); +}); +test("captured chosen-option mismatch clears a named option", () => { + let input = stanceInput(); + input.candidates[0]!.answers.chosen_option = confidentChoice("beta"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); +test("fresh input card replacement does not replace the earlier captured card", () => { + let { context, entry, role } = stanceFrame(); + let card = role.linkedCard; + context.input.linkedCards = new Map(); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: "alpha" }); + expect(role.linkedCard).toBe(card); +}); + +test("reverse substring collision rejects one indirectly named card label", () => { + let label = "Start with a durable queue."; + let input = stanceInput("support", "I support a durable queue."); + input.state = stateWithOption("Which provider?", "provider", "alpha", label); + linkContributionCard(input); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [{ id: "alpha", label }, { id: "other", label: "Start with a durable" }], + }]]); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); +test("raw chosen-option conflict blocks fallback independently of raw option", () => { + let input = stanceInput(); + input.candidates[0]!.answers.option = rawChoice("alpha"); + input.candidates[0]!.answers.chosen_option = rawChoice("beta"); + let { context, entry, role } = stanceFrame(input); + expect(role.chosenOption).toBeUndefined(); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.ts b/apps/server/src/conversation-plan/policy-candidate-stance.ts new file mode 100644 index 00000000..30334a60 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.ts @@ -0,0 +1,121 @@ +import { currentScopedProposal } from "./events"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { namesCardOption, namesStanceOption } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; + +/** Invoke only for captured support/objection; a result progresses without application. */ +export function runStance( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, reviews } = context; + let { index, candidate, outcome } = entry; + let { thread, option, linkedCard, chosenOption } = frame; + let role = frame.role as "support" | "objection"; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if (!thread || message.author.kind !== "member") { + outcome.status = "review"; + outcome.gate = "stance target or human authority unclear"; + return undefined; + } + if ( + thread.status === "decided" && role === "objection" + && noul(candidate.answers, "material_objection") >= 0.68 + ) { + if (reviews.some((item) => item.kind === "reopening" && item.targetId === thread.id)) { + outcome.status = "review"; + outcome.gate = "reopening review already proposed from this message"; + return undefined; + } + let id = stableId(channelId, message.id, index, "reopening-candidate"); + proposed = { + ...base(thread.id, "candidate.proposed"), + type: "candidate.proposed", + source: source("reopening"), + candidate: { id, kind: "reopening", text: candidate.quote }, + }; + reviews.push({ id, kind: "reopening", targetId: thread.id }); + } else { + let existingOption = option && !["new", "none"].includes(option) + && thread.contributions.some((item) => item.id === option && item.kind === "option") + ? option + : undefined; + if (linkedCard) { + let named = linkedCard.options.filter(item => namesStanceOption(candidate.quote, item.label)); + let selected = named.length === 1 ? named[0] : undefined; + let unique = selected + && !linkedCard.options.some(item => + item.id !== selected.id + && (namesCardOption(item.label, selected.label) + || namesCardOption(selected.label, item.label)) + ) + && thread.contributions.some(item => + item.kind === "option" && item.id === selected.id + && (item.displayLabel ?? item.text) === selected.label + ); + if ( + !selected || !unique || linkedCard.cardId !== thread.questionnaireId + || choice(first, "thread_target") !== thread.id + ) existingOption = undefined; + else if (existingOption && existingOption !== selected.id) { + existingOption = undefined; + } else if (!existingOption) { + if ( + ["none", selected.id].includes( + candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : "none", + ) + && ["none", selected.id].includes( + candidate.answers.chosen_option?.type === "choice" + ? candidate.answers.chosen_option.choice + : "none", + ) + ) existingOption = selected.id; + } + } + if (chosenOption && chosenOption !== "none" && chosenOption !== existingOption) { + existingOption = undefined; + } + let scopedProposalId: string | null = null; + let scoped = thread.pendingScopedChoice; + let participant = message.author.handle; + if ( + role === "objection" && scoped?.scope === "spike" + && thread.questionnaireId === scoped.cardId + && choice(candidate.answers, "option") === scoped.optionId + && choice(candidate.answers, "chosen_option") === scoped.optionId + && linkedCard?.cardId === scoped.cardId + && linkedCard.options.some(item => item.id === scoped.optionId && item.label === scoped.label) + ) { + let proposal = currentScopedProposal(thread, working.events); + if ( + proposal && (participant === scoped.proposer + || working.events.some(item => + item.type === "scoped-choice.agreed" && item.proposalId === proposal.id + && item.source.author.kind === "member" + && item.source.author.handle === participant + )) + ) scopedProposalId = proposal.id; + } + proposed = { + ...base(thread.id, "stance.changed"), + type: "stance.changed", + source: source(role), + position: role === "support" ? "support" : "oppose", + optionId: existingOption, + scopedProposalId, + }; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts new file mode 100644 index 00000000..4473bb78 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts @@ -0,0 +1,65 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Pure domain history for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts factories1092–1107 and early verification/condition1108–1159. +export function verificationInput(): PolicyInput { + let input = roleInput("reason", "Backups are verified."); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + let citation = ( + saved: Chat.Entry, + role: ConversationPlan.SourceRole, + ): ConversationPlan.SourceRef => ({ + messageId: saved.id, + author: saved.author as ConversationPlan.SourceAuthor, + quote: saved.text, + start: 0, + end: saved.text.length, + role, + }); + let historyBase = (id: string) => ({ + id, + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + }); + let proposal = message("proposal-message", "Let's use Alpha.", "Jules"); + input.state = applyInference(input.state, { + ...historyBase("proposal-event"), + type: "settle.suggested", + source: citation(proposal, "resolution"), + optionId: "alpha", + }, proposal); + let deferred = message("defer-message", "Let's verify backups before choosing.", "Jules"); + input.state = applyInference(input.state, { + ...historyBase("withdrawal-event"), + type: "stance.changed", + position: "neutral", + source: citation(deferred, "withdrawal"), + optionId: "alpha", + scopedProposalId: null, + }, deferred); + input.state = applyInference(input.state, { + ...historyBase("defer-event"), + type: "settle.deferred", + proposalId: "proposal-event", + source: citation(deferred, "constraint"), + }, deferred); + input.first.evidence = { type: "noul", noul: 0.95 }; + return input; +} + +export function verificationFrame(input = verificationInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + return { context, entry, role }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.test.ts b/apps/server/src/conversation-plan/policy-candidate-verification.test.ts new file mode 100644 index 00000000..c3741b88 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { applyEvent } from "./events"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { + verificationFrame, + verificationInput, +} from "./policy-candidate-verification.test-fixtures"; +import { roleInput } from "./policy-candidate-entry.test-fixtures"; +import { activeSettleDeferral } from "./preference"; + +test("valid deferred domain history resumes exact proposal and marks its outcome before skipping", () => { + let input = verificationInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = verificationFrame(input); + let before = structuredClone(input); + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + let event = context.events[0]!; + expect(event.type).toBe("settle.resumed"); + if (event.type !== "settle.resumed") throw new Error("expected resume"); + expect(event.proposalId).toBe("proposal-event"); + expect(event.deferredEventId).toBe("defer-event"); + expect(entry.outcome.eventIds).toEqual([event.id]); + expect(entry.outcome.status).toBe("accepted"); + expect(entry.outcome.gate).toBe("accepted"); + expect(activeSettleDeferral(context.working.threads[0]!, context.working.events)).toBeUndefined(); + expect(replay(context.working.events)).toEqual(context.working); + expect(input).toEqual(before); +}); + +test.each(["weak evidence", "wrong chosen option", "wrong subject"])( + "verification %s falls through without mutation", + kind => { + let input = verificationInput(); + if (kind === "weak evidence") input.first.evidence = { type: "noul", noul: 0.79 }; + if (kind === "wrong chosen option") { + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "other", + confidence: 0.95, + probabilities: { other: 0.95, none: 0.05 }, + }; + } + if (kind === "wrong subject") { + input.message.text = "Latency is verified."; + input.candidates[0]!.quote = input.message.text; + input.candidates[0]!.end = input.message.text.length; + } + let { context, entry, role } = verificationFrame(input); + let working = context.working; + expect(runCandidateVerification(context, entry, role)).toEqual({ spikeLabel: undefined }); + expect(context.working).toBe(working); + expect(context.events).toHaveLength(0); + }, +); + +test("a discarded working thread rejects verification while captured thread history stays open", () => { + let { context, entry, role } = verificationFrame(); + context.working = applyEvent(context.working, { + id: "discard", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: context.working.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1001, + }); + let working = context.working; + expect(role.thread!.status).toBe("exploring"); + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + expect(context.working).toBe(working); + expect(entry.outcome.gate).toBe("verification needs review"); + expect(entry.outcome.status).toBe("review"); + expect(context.events).toHaveLength(0); +}); + +test.each([false, true])( + "spike preference caches label or skips its immediate condition: %s", + conditional => { + let input = roleInput("support", "I'd pick Alpha for a spike;"); + if (conditional) input.message.text += " if latency stays low."; + let { context, entry, role } = verificationFrame(input); + let result = runCandidateVerification(context, entry, role); + if (conditional) { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe("spike choice has an immediate condition"); + } else { + expect(result).toEqual({ spikeLabel: "Alpha" }); + entry.candidate.quote = "I'd pick Beta for a spike;"; + expect(result!.spikeLabel).toBe("Alpha"); + } + expect(context.events).toHaveLength(0); + }, +); diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.ts b/apps/server/src/conversation-plan/policy-candidate-verification.ts new file mode 100644 index 00000000..dfd1d5d5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.ts @@ -0,0 +1,80 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { immediateSpikeCondition, OWNED_QUOTE_MIN } from "./policy-cues"; +import { choice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { activeSettleDeferral } from "./preference"; +import { spikePreferenceLabel } from "./validation-fields"; + +/** Undefined handles/skips this candidate; continuation carries only its cached spike label. */ +export function runCandidateVerification( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +) { + let { message, first, events } = context; + let { index, candidate, outcome } = entry; + let { thread, role } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + let deferred = thread && activeSettleDeferral(thread, working.events); + let subject = deferred?.source.quote.match( + /\b(?:verify|check|test)\s+(?:the\s+)?([\p{L}][\p{L}\p{N}-]*)/iu, + )?.[1]?.replace(/s$/i, ""); + let verifiedSubject = subject && new RegExp( + `^(?:the\\s+[\\p{L}\\p{N}-]+\\s+test\\s+passed\\s+and\\s+)?(?:` + + `(?:the\\s+)?${subject}s?\\s+(?:(?:have|has|had)\\s+been|is|are|was|were)\\s+verified` + + `|we\\s+(?:have\\s+)?verified\\s+(?:the\\s+)?${subject}s?)` + + `[.!]?$`, + "iu", + ).test(candidate.quote.trim()); + let verificationOption = choice(candidate.answers, "chosen_option"); + let mentionedOption = choice(candidate.answers, "option"); + if ( + thread && deferred && thread.pendingSettle && message.author.kind === "member" + && (role === "reason" || role === "resolution") + && choice(first, "thread_target") === thread.id + && noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && (noul(first, "evidence") >= 0.8 || noul(first, "explicit_resolution") >= 0.8) + && (noul(candidate.answers, "planning_substance") >= 0.8 + || noul(candidate.answers, "explicit_resolution") >= 0.85) + && (verificationOption === "none" + || verificationOption === thread.pendingSettle.optionId) + && (mentionedOption === "none" + || mentionedOption === thread.pendingSettle.optionId) + && verifiedSubject + ) { + let resumed: Event = { + ...base(thread.id, "settle.resumed"), + type: "settle.resumed", + proposalId: deferred.proposalId, + deferredEventId: deferred.id, + source: source("verification"), + }; + try { + working = applyInference(working, resumed, message); + events.push(resumed); + outcome.eventIds.push(resumed.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "verification needs review"; + } + return undefined; + } + let spikeLabel = spikePreferenceLabel(candidate.quote); + if (spikeLabel && immediateSpikeCondition(message.text, candidate.end)) { + outcome.status = "review"; + outcome.gate = "spike choice has an immediate condition"; + return undefined; + } + return { spikeLabel }; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts new file mode 100644 index 00000000..116c1ecf --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { + ordinaryFrame, + ordinaryInput, + withdrawCurrent, +} from "./policy-candidate-ordinary.test-fixtures"; +import { runWithdrawal } from "./policy-candidate-withdrawal"; + +test.each([0.8, 0.799])("withdrawal threshold %s retains a valid owned proposal", probability => { + let input = ordinaryInput(); + input.candidates[0]!.answers.withdraws_pending_settle = { type: "noul", noul: probability }; + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("withdrawal"); + let before = structuredClone(context.working); + let result = runWithdrawal(context, entry, role); + if (probability === 0.8) { + expect(result?.proposed).toMatchObject({ + type: "stance.changed", + optionId: "alpha", + position: "neutral", + source: { role: "withdrawal" }, + }); + } else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("pending withdrawal needs a clear owned proposal"); + } + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); +}); +test.each(["If I withdraw that choice.", "I withdraw that choice?"])( + "conditional/question withdrawal is handled: %s", + quote => { + let input = ordinaryInput(); + input.message.text = quote; + Object.assign(input.candidates[0]!, { quote, end: quote.length }); + let { context, entry, role } = ordinaryFrame(input); + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + }, +); +test("withdrawal keeps captured pending/thread while searching current event history", () => { + let input = ordinaryInput(); + let { context, entry, role } = ordinaryFrame(input); + context.working = withdrawCurrent(input); + expect(replay(context.working.events)).toEqual(context.working); + expect(role.pending).toBeDefined(); + expect(runWithdrawal(context, entry, role)?.proposed?.type).toBe("stance.changed"); +}); +test("missing current authored proposal rejects the previously captured pending", () => { + let { context, entry, role } = ordinaryFrame(); + context.working = { + ...context.working, + events: context.working.events.filter(event => event.type !== "settle.suggested"), + }; + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); +}); + +test("another member cannot withdraw the captured owned proposal", () => { + let input = ordinaryInput(); + input.message.author = { kind: "member", handle: "Mina" }; + let { context, entry, role } = ordinaryFrame(input); + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("pending withdrawal needs a clear owned proposal"); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts b/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts new file mode 100644 index 00000000..c3656664 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts @@ -0,0 +1,51 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runWithdrawal( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { message } = context; + let { candidate, outcome } = entry; + let { pending, thread, option } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + let acceptedProposal = pending + && working.events.findLast(event => + event.type === "settle.suggested" && event.threadId === thread?.id + && event.optionId === pending.optionId + && event.source.messageId === pending.messageId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + ); + if ( + !thread || thread.status === "decided" || thread.status === "discarded" + || message.author.kind !== "member" || !pending || !acceptedProposal + || pending.proposer !== message.author.handle || option !== pending.optionId + || noul(candidate.answers, "withdraws_pending_settle") < 0.8 + || /\b(?:if|unless|might|would|could)\b/i.test(candidate.quote) + || candidate.quote.trim().endsWith("?") + ) { + outcome.status = "review"; + outcome.gate = "pending withdrawal needs a clear owned proposal"; + return undefined; + } + proposed = { + ...base(thread.id, "stance.changed"), + type: "stance.changed", + source: source("withdrawal"), + optionId: pending.optionId, + scopedProposalId: null, + position: "neutral", + }; + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts new file mode 100644 index 00000000..434231ba --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts @@ -0,0 +1,64 @@ +import { expect } from "bun:test"; +import { initialState } from "./domain"; +import { createPolicyContext, type PolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption, stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Setup-only fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements800–876. Original full-policy callbacks remain deferred whole. +export function groupInput(quotes: readonly string[] = ["Alpha", "Beta", "Gamma"]): PolicyInput { + let text = quotes.join(" "); + let offset = 0; + return { + channelId: "channel", + message: message("candidate-setup", text), + state: initialState(), + first: { + ...first({ new_question: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + }, + candidates: quotes.map(quote => { + let start = offset; + offset += quote.length + 1; + return { + start, + end: start + quote.length, + quote, + answers: { ...follow({ role: "option", thread: "new" }) }, + }; + }), + }; +} + +export function choiceInput(ids: readonly string[] = ["alpha", "beta"]): PolicyInput { + let input = groupInput(ids.map(id => `We should use ${id}.`)); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.state = addThreadWithOption(input.state, "other", "Which other provider?", "beta", "Beta"); + let other = input.state.threads.find(thread => thread.id === "other")!; + input.state.threads[0]!.contributions.push({ ...other.contributions[0]!, id: "beta" }); + input.first.act = confidentChoice("proposal"); + input.first.thread_target = confidentChoice("provider"); + input.first.new_option = { type: "noul", noul: 0.95 }; + for (let [index, candidate] of input.candidates.entries()) { + candidate.answers = { + ...follow({ role: "support", thread: "provider" }), + chosen_option: confidentChoice(ids[index]!), + option: confidentChoice(ids[index]!), + new_option: { type: "noul", noul: 0.95 }, + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }; + } + return input; +} + +export function prepareCandidateContext(input: PolicyInput): PolicyContext { + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(runRemainingTerminals(context)).toBeUndefined(); + return context; +} diff --git a/apps/server/src/conversation-plan/policy-compound.ts b/apps/server/src/conversation-plan/policy-compound.ts new file mode 100644 index 00000000..a51bf9c2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-compound.ts @@ -0,0 +1,187 @@ +import { applyInference } from "./domain"; +import type { DirectFacts, PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, moderateChoice, noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { + explicitListQuotes, + isAttributedCompoundDecision, + isExplicitCompoundDecision, + multiAlternativeQuotes, +} from "./quotes"; + +export function questionListCorroboration( + context: PolicyContext, + facts: DirectFacts, +): PolicyResult | undefined { + let { input, message, first, events, reviews } = context; + let { questionList, exactDirectAlternatives, directBounds } = facts; + let questionBeforeOptions = message.text.indexOf("?") >= 0 + && message.text.indexOf("?") < (input.candidates[0]?.start ?? 0); + let listed = explicitListQuotes(message.text); + if ( + (listed.length >= 3 || input.candidates.length >= 3) + && /,\s*or\s+/i.test(message.text) + && (questionBeforeOptions + || !message.text.trimEnd().endsWith("?") + && multiAlternativeQuotes(message.text).length === 0) + ) { + let exact = listed.length === input.candidates.length + && listed.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + ); + let open = input.state.threads.filter(thread => + !["decided", "discarded"].includes(thread.status) + ); + let thread = open.length === 1 ? open[0] : undefined; + let card = thread && input.linkedCards?.get(thread.id); + let bareList = !questionBeforeOptions; + let supported = !bareList || message.author.kind === "member" && !!thread + && (!card || card.options.length === 0) + && choice(first, "thread_target") === thread.id + && noul(first, "new_option") >= 0.8 + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && roleChoice(candidate.answers, first) === "option" + && choice(candidate.answers, "thread") === thread.id + && choice(candidate.answers, "option") === "new" + && noul(candidate.answers, "new_option") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && !thread.contributions.some(item => + item.kind === "option" + && item.text.trim().toLowerCase() === candidate.quote.trim().toLowerCase() + ) + ); + if (!exact || !supported) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "alternative list source needs review", + eventIds: [], + })), + policyGate: "alternative list source needs review", + }; + } + } + if ( + questionList && !(message.author.kind === "member" + && exactDirectAlternatives && directBounds + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= 0.8 + && ["role", "thread", "option"].every(key => + candidate.answers[key]?.type === "choice" + && candidate.answers[key].confidence >= 0.8 + ) + && moderateChoice(candidate.answers, "role", "option", 0.8) + && moderateChoice(candidate.answers, "thread", "new", 0.8) + && moderateChoice(candidate.answers, "option", "new", 0.8) + && candidate.answers.new_option?.type === "noul" + && noul(candidate.answers, "new_option") >= 0.85 + && candidate.answers.duplicate?.type === "noul" + && noul(candidate.answers, "duplicate") <= 0.2 + )) + ) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "question alternatives need candidate corroboration", + eventIds: [], + })), + policyGate: "question alternatives need candidate corroboration", + }; + } +} + +export function compoundAttribution(context: PolicyContext): PolicyResult | undefined { + let { input, message, events, reviews } = context; + if (isAttributedCompoundDecision(message.text, input.candidates)) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "compound decision attribution unclear", + eventIds: [], + })), + policyGate: "compound decision attribution unclear", + }; + } +} + +export function compoundOpenings(context: PolicyContext): PolicyResult | undefined { + let { input, channelId, message, first, reviews } = context; + if ( + message.author.kind === "member" && input.state.threads.length === 0 + && isExplicitCompoundDecision(message.text, input.candidates) + && noul(first, "new_question") >= 0.55 + && noul(first, "enough_purpose") >= 0.8 + && choice(first, "act") === "question" + && choice(first, "thread_target") === "new" + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= 0.8 + && moderateChoice(candidate.answers, "role", "question", 0.55) + && choice(candidate.answers, "thread") === "new" + && noul(candidate.answers, "duplicate") < 0.3 + ) + ) { + let staged = input.state; + let openings: Event[] = []; + for (let [index, candidate] of input.candidates.entries()) { + let threadId = `thread:${stableId(channelId, message.id, index, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, index, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "question", + }, + question: candidate.quote, + }; + try { + staged = applyInference(staged, opening, message); + } catch { + openings = []; + break; + } + openings.push(opening); + } + if (openings.length === input.candidates.length) { + return { + events: openings, + selectedTarget: openings[0]!.threadId, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [openings[index]!.id], + targetId: openings[index]!.threadId, + })), + policyGate: "explicit compound questions accepted", + }; + } + } +} diff --git a/apps/server/src/conversation-plan/policy-context.ts b/apps/server/src/conversation-plan/policy-context.ts new file mode 100644 index 00000000..2db76059 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-context.ts @@ -0,0 +1,96 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import type { Event, PolicyInput, PolicyResult, State } from "./policy-types"; +import { directAlternativeQuotes, type QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; + +export type DirectFacts = { + optionLabels: string[]; + directAlternatives: ReturnType; + exactDirectAlternatives: boolean; + questionKey: string; + matchingThreads: ConversationPlan.Thread[]; + directBounds: boolean; + questionList: boolean; +}; +export type CandidateRun = { + seenOptionLabels: Set; + sequentialChoiceQuestions: boolean; + optionGroup: string | undefined; + competingMessageTargets: Set; +}; +export type PolicyContext = { + input: PolicyInput; + channelId: string; + message: Chat.Entry; + first: Record; + events: Event[]; + reviews: PolicyResult["candidates"]; + outcomes: PolicyResult["outcomes"]; + working: State; + selectedTarget: string | undefined; + facts?: DirectFacts; + candidateRun?: CandidateRun; + directQuestion?: boolean; + quotedOption?: ( + candidate: QuoteCandidate, + index: number, + threadId: string, + state: State, + ) => Event; +}; + +/** Initialize before running terminals; quote budget is checked before any other input reads. */ +export function createPolicyContext(input: PolicyInput): PolicyContext { + assertQuoteBudget(input.candidates); + let { channelId, message, first } = input; + let events: Event[] = []; + let reviews: PolicyResult["candidates"] = []; + let outcomes: PolicyResult["outcomes"] = []; + let working = input.state; + let selectedTarget: string | undefined = undefined; + + return { input, channelId, message, first, events, reviews, outcomes, working, selectedTarget }; +} + +/** Called only after the runner has rejected ineligible messages. */ +export function directFacts(context: PolicyContext): DirectFacts { + let { input, message } = context; + // A new question expressed as several options has no existing thread ID for pass 2 to target. + // Keep the question and each option tied to exact spans of this one bounded message. + let optionLabels = input.candidates.map(candidate => + candidate.quote.trim().replace(/\s+/g, " ").toLowerCase() + ); + let directAlternatives = directAlternativeQuotes(message.text); + let exactDirectAlternatives = directAlternatives.length === input.candidates.length + && directAlternatives.length >= 2 + && directAlternatives.every((quote, index) => + quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + && quote.quote === input.candidates[index]?.quote + ); + let questionKey = message.text.trim().replace(/\s+/g, " ").toLowerCase(); + let matchingThreads = input.state.threads.filter(thread => + thread.status !== "discarded" + && thread.question.trim().replace(/\s+/g, " ").toLowerCase() === questionKey + ); + let directBounds = message.text.length <= 500 + && new Set(optionLabels).size === input.candidates.length + && input.candidates.every(candidate => + candidate.quote.length <= 500 && !!candidate.quote.trim() + && message.text.slice(candidate.start, candidate.end) === candidate.quote + ); + let questionList = directAlternatives.length >= 3 + && message.text.indexOf("?") >= 0 + && message.text.indexOf("?") < directAlternatives[0]!.start; + + return { + optionLabels, + directAlternatives, + exactDirectAlternatives, + questionKey, + matchingThreads, + directBounds, + questionList, + }; +} diff --git a/apps/server/src/conversation-plan/policy-cues.ts b/apps/server/src/conversation-plan/policy-cues.ts new file mode 100644 index 00000000..42653cd8 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-cues.ts @@ -0,0 +1,45 @@ +export const OWNED_QUOTE_MIN = 0.7; +export const COMMA_ALTERNATIVES = + /^[^,;?]{2,80}(?:,\s*[^,;?]{2,80})+(?:,\s*or\s+|\s+or\s+)[^,;?]{2,80}\??$/i; +export function directResolution(text: string): boolean { + return /^\s*(?:and\s+)?(?:we(?: have|['’]ve)? decided|let['’]?s (?:just )?(?:make|do|use|choose|pick|go with)|we(?:'re| are) going with)\b/i + .test(text) + && !/\b(?:please (?:show|quote|display)|said|told|claimed|reported|according to|apparently|seems?|i guess|maybe|perhaps|i prefer)\b/i + .test(text) + && !/[🙄]/u.test(text); +} +export function directOwnedCommitment(text: string): boolean { + return directResolution(text) && !text.trim().endsWith("?") + && !/\b(?:not|don't|do not|without|if|unless)\b/i.test(text); +} +export function directRecommendation(text: string): boolean { + return /^\s*(?:and\s+)?(?:we\s+should|i\s+think\s+we\s+should)\s+(?:also\s+)?(?:do|use|choose|pick|go with|host|store|send)\b/i + .test(text) + && !/\b(?:not|don't|do not|without|maybe|perhaps|probably|i guess|if|unless|said|asked)\b/i + .test(text) + && !text.trim().endsWith("?"); +} +export function directReopening(text: string): boolean { + return /\b(?:reopen|reconsider|let's revisit|we should revisit)\b/i.test(text) + && !/\b(?:please (?:show|quote|display)|said|apparently)\b/i.test(text); +} + +export function namesCardOption(quote: string, label: string): boolean { + let escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(? 80) return false; + let content = + /^Start with (?:a|an|the) ([\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*(?: [\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*){1,5})[.!?]?$/iu + .exec(label)?.[1]; + return !!content + && ["a", "an", "the"].some(article => namesCardOption(quote, `${article} ${content}`)); +} + +export function immediateSpikeCondition(text: string, end: number): boolean { + return /^\s*(?:[,;:]\s*)?(?:if|unless|provided(?:\s+that)?)(?=\s|$)/iu + .test(text.slice(end, end + 160)); +} diff --git a/apps/server/src/conversation-plan/policy-declarative.test.ts b/apps/server/src/conversation-plan/policy-declarative.test.ts new file mode 100644 index 00000000..d947aea7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-declarative.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; +import type { JevAnswer } from "./jev"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption, terminalPolicy as planEvents } from "./policy-terminal.test-fixtures"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("captures two sourced options from an owned declarative explanation of an open topic", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let current = message( + "access-alternatives", + "By agent access I mean Copilot or bring-your-own API keys.", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "Copilot", start: 23, end: 30 }, + { quote: "bring-your-own API keys", start: 34, end: 57 }, + ]); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.93 }), + thread_target: confidentChoice("access-thread"), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "none", thread: "access-thread" }), + new_option: { type: "noul", noul: 0.05 }, + duplicate: { type: "noul", noul: 0.04 }, + }, + })), + }); + expect(output.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(output.events.map(event => event.type === "option.added" && event.contribution.text)) + .toEqual(quotes.map(quote => quote.quote)); + expect(output.events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, role: "option" })), + ); + expect(output.events.some(event => + event.type === "settle.suggested" + || event.type === "decision.recorded" + )).toBe(false); +}); +test("recovers a declarative pair when the whole-message target resolves one weaker span", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let current = message( + "observed-access-pair", + "By agent access I mean Copilot or bring-your-own API keys.", + ); + let quotes = extractQuotes(current.text); + let triage = { + ...first({ + new_option: 0.93, + c0_owned_unretracted: 0.79, + c1_owned_unretracted: 0.86, + }), + thread_target: { + type: "choice" as const, + choice: "access-thread", + confidence: 0.98, + probabilities: { "access-thread": 0.98, none: 0.02 }, + }, + }; + let candidates: Parameters[0]["candidates"] = quotes.map((quote, index) => { + let thread: JevAnswer = index + ? { + type: "choice" as const, + choice: "access-thread", + confidence: 0.96, + probabilities: { "access-thread": 0.98, none: 0.02 }, + } + : { + type: "choice" as const, + choice: "access-thread", + confidence: 0.45, + probabilities: { "access-thread": 0.59, none: 0.29, other: 0.09, new: 0.03 }, + }; + return { + ...quote, + answers: { + ...follow({ role: index ? "option" : "none", thread: "access-thread" }), + thread, + new_option: { type: "noul" as const, noul: index ? 0.86 : 0.4 }, + duplicate: { type: "noul" as const, noul: index ? 0.13 : 0.37 }, + }, + }; + }); + let assess = ( + firstAnswers = triage as Record, + candidateAnswers = candidates, + ) => + planEvents({ + channelId: "channel", + message: current, + state, + first: firstAnswers, + candidates: candidateAnswers, + }); + expect(assess().events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(assess().events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, role: "option" })), + ); + expect( + assess({ + ...triage, + thread_target: { + ...triage.thread_target, + probabilities: { "access-thread": 0.84, none: 0.16 }, + }, + }).events, + ).toEqual([]); + expect( + assess(triage, [{ + ...candidates[0]!, + answers: { + ...candidates[0]!.answers, + thread: { + type: "choice", + choice: "access-thread", + confidence: 0.35, + probabilities: { "access-thread": 0.52, none: 0.4, other: 0.08 }, + }, + }, + }, candidates[1]!]).events, + ).toEqual([]); + expect( + assess(triage, [candidates[0]!, { + ...candidates[1]!, + answers: { ...candidates[1]!.answers, thread: candidates[0]!.answers.thread }, + }]).events, + ).toEqual([]); + expect( + assess(triage, [{ + ...candidates[0]!, + answers: { + ...candidates[0]!.answers, + duplicate: { type: "noul", noul: 0.72 }, + }, + }, candidates[1]!]).events, + ).toEqual([]); +}); +test("a definition of CI failure causes needs review without option evidence", () => { + let state = stateWithOption( + "How should we improve CI reliability?", + "quality-thread", + "existing-quality", + "Retry failed jobs automatically.", + ); + let current = message( + "observed-definition", + "By CI reliability I mean flaky tests or missing dependencies.", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "flaky tests", start: 25, end: 36 }, + { quote: "missing dependencies", start: 40, end: 60 }, + ]); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ c0_owned_unretracted: 0.94, c1_owned_unretracted: 0.95 }), + new_option: { type: "noul", noul: 0.59 }, + thread_target: { + type: "choice", + choice: "quality-thread", + confidence: 0.84, + probabilities: { "quality-thread": 0.84, none: 0.16 }, + }, + significance: { + type: "score", + score: 1.47, + confidence: 0.5, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "changes work" }, + probabilities: { "0": 0.2, "1": 0.2, "2": 0.53, "3": 0.07 }, + }, + }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: index ? "none" : "reason", thread: "quality-thread" }), + role: index + ? { + type: "choice" as const, + choice: "none", + confidence: 0.38, + probabilities: { none: 0.38, reason: 0.35, option: 0.27 }, + } + : { + type: "choice" as const, + choice: "reason", + confidence: 0.45, + probabilities: { reason: 0.45, none: 0.39, option: 0.16 }, + }, + thread: { + type: "choice" as const, + choice: "quality-thread", + confidence: index ? 0.84 : 0.99, + probabilities: { "quality-thread": index ? 0.84 : 0.99, none: index ? 0.16 : 0.01 }, + }, + new_option: { type: "noul" as const, noul: index ? 0.11 : 0.16 }, + duplicate: { type: "noul" as const, noul: index ? 0.18 : 0.23 }, + }, + })), + }); + expect(output.events).toEqual([]); + expect(output.policyGate).toBe("declarative options need review"); +}); diff --git a/apps/server/src/conversation-plan/policy-declarative.ts b/apps/server/src/conversation-plan/policy-declarative.ts new file mode 100644 index 00000000..68bae41c --- /dev/null +++ b/apps/server/src/conversation-plan/policy-declarative.ts @@ -0,0 +1,108 @@ +import { applyInference } from "./domain"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import type { PolicyContext } from "./policy-context"; +import { choice, noul } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { declarativeAlternativeQuotes } from "./quotes"; + +export function declarativePair(context: PolicyContext): PolicyResult | undefined { + let { input, message, first, events, reviews, outcomes } = context; + let { optionLabels } = context.facts!; + let quotedOption = context.quotedOption!; + let declarative = declarativeAlternativeQuotes(message.text); + if (declarative.length === 2) { + let target = choice(first, "thread_target"); + let thread = input.state.threads.find(item => item.id === target); + let wholeTarget = first.thread_target; + let certainCandidates = input.candidates.map(candidate => + choice(candidate.answers, "thread") === target + ); + let corroboratedTarget = wholeTarget?.type === "choice" + && (wholeTarget.probabilities[target ?? ""] ?? 0) >= 0.95 + && certainCandidates.some(Boolean); + let candidateTargets = input.candidates.map((candidate, index) => { + if (certainCandidates[index]) return true; + let answer = candidate.answers.thread; + if (!corroboratedTarget || answer?.type !== "choice" || answer.choice !== target) { + return false; + } + let probability = answer.probabilities[target ?? ""] ?? 0; + let alternative = Math.max( + 0, + ...Object.entries(answer.probabilities) + .filter(([key]) => key !== target).map(([, value]) => value), + ); + return probability >= 0.55 && probability - alternative >= 0.2; + }); + let existing = new Set( + thread?.contributions.filter(item => item.kind === "option") + .map(item => item.text.trim().replace(/[.!?]+$/, "").replace(/\s+/g, " ").toLowerCase()), + ); + let optionEvidence = noul(first, "new_option") >= 0.8 + || input.candidates.some(candidate => + noul(candidate.answers, "new_option") >= 0.75 + || candidate.answers.role?.type === "choice" + && candidate.answers.role.choice === "option" + && (candidate.answers.role.probabilities.option ?? 0) >= 0.75 + ); + let eligible = message.author.kind === "member" && !!thread + && !["decided", "discarded"].includes(thread.status) + && optionEvidence + && declarative.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + && noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && candidateTargets[index] + && noul(input.candidates[index]!.answers, "duplicate") + < (certainCandidates[index] ? 0.3 : 0.5) + && !existing.has(optionLabels[index]) + ) + && input.candidates.length === 2 + && new Set(optionLabels).size === 2; + if (!eligible || !thread) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "declarative options need review", + eventIds: [], + })), + policyGate: "declarative options need review", + }; + } + let staged = input.state; + let additions: Event[] = []; + for (let [index, candidate] of input.candidates.entries()) { + let added = quotedOption(candidate, index, thread.id, staged); + try { + staged = applyInference(staged, added, message); + } catch { + return { + events, + candidates: reviews, + outcomes, + policyGate: "declarative options rejected", + }; + } + additions.push(added); + } + return { + events: additions, + selectedTarget: thread.id, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [additions[index]!.id], + targetId: thread.id, + })), + policyGate: "declarative options accepted", + }; + } +} diff --git a/apps/server/src/conversation-plan/policy-direct-alternatives.ts b/apps/server/src/conversation-plan/policy-direct-alternatives.ts new file mode 100644 index 00000000..21787d1a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-alternatives.ts @@ -0,0 +1,83 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { Event, PolicyResult } from "./policy-types"; + +export function directAlternatives( + context: PolicyContext, + directQuestion: boolean, +): PolicyResult | undefined { + let { input, channelId, message, events, reviews, outcomes } = context; + let { directBounds } = context.facts!; + let quotedOption = context.quotedOption!; + let working = context.working; + // Retain prior successful inference even when a later option rejects or throws. + try { + if (directQuestion && directBounds) { + let threadId = `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + working = applyInference(working, opening, message); + } catch { + return { events, candidates: reviews, outcomes, policyGate: "direct question rejected" }; + } + let directEvents: Event[] = [opening]; + let directOutcomes: PolicyResult["outcomes"] = []; + for (let [index, candidate] of input.candidates.entries()) { + let added = quotedOption(candidate, index, threadId, working); + try { + working = applyInference(working, added, message); + } catch { + return { + events: [], + candidates: reviews, + outcomes: input.candidates.map(item => ({ + start: item.start, + end: item.end, + status: "review", + gate: "direct option rejected", + eventIds: [], + })), + policyGate: "direct option rejected", + }; + } + directEvents.push(added); + directOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: index === 0 ? [opening.id, added.id] : [added.id], + targetId: threadId, + }); + } + return { + events: directEvents, + selectedTarget: threadId, + candidates: reviews, + outcomes: directOutcomes, + policyGate: "direct alternatives accepted", + }; + } + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-direct-recovery.test.ts b/apps/server/src/conversation-plan/policy-direct-recovery.test.ts new file mode 100644 index 00000000..c747ec4c --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-recovery.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { + addMatchingThread, + confidentChoice, + inputFor, + prefix, + seedOptions, + terminalPolicy, +} from "./policy-initial.test-fixtures"; +import { MAX_EVENTS } from "./validation"; + +let question = "Should audit logs go in PostgreSQL or object storage?"; + +function recovery() { + let input = inputFor(question); + input.state = addMatchingThread(input, "audit"); + return input; +} + +test.each(["audit", "new"])("an exact question recovers missing options with target %s", target => { + let input = recovery(); + input.first.thread_target = confidentChoice(target); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct options recovered"); + expect(result.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(result.events.map(event => event.observedThreadVersion)).toEqual([1, 2]); + expect(result.events.map(event => "source" in event ? event.source?.quote : undefined)) + .toEqual(input.candidates.map(candidate => candidate.quote)); + expect(result.selectedTarget).toBe("audit"); + expect(input).toEqual(before); +}); + +test("partial recovery ignores existing wording and adds only its missing option", () => { + let input = recovery(); + seedOptions(input, 1); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.events).toHaveLength(1); + expect(result.outcomes.map(outcome => outcome.gate)).toEqual([ + "option already exists", + "accepted", + ]); + expect(result.events[0]!.observedThreadVersion).toBe(2); + expect(input).toEqual(before); +}); + +test("a complete repeated question returns a terminal result without new events", () => { + let input = recovery(); + seedOptions(input, 2); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("question already complete"); + expect(result.events).toEqual([]); + expect(result.outcomes.every(outcome => outcome.gate === "option already exists")).toBe(true); +}); + +test("ambiguous exact matches and unclear targets are terminal without recovery", () => { + let input = recovery(); + input.state = addMatchingThread(input, "other-audit"); + expect(terminalPolicy(input).policyGate).toBe("question recovery unclear"); + input = recovery(); + input.first.thread_target = confidentChoice("none"); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("question recovery unclear"); + expect(result.events).toEqual([]); + expect(result.outcomes.every(outcome => outcome.status === "ignored")).toBe(true); +}); + +test("discarded exact questions fall through rather than being recovered", () => { + let input = recovery(); + input.state = applyEvent(input.state, { + id: "discard-audit", + type: "thread.discarded", + threadId: "audit", + observedThreadVersion: 1, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1001, + }); + let before = structuredClone(input); + expect(prefix(input)).toBeUndefined(); + expect(input).toEqual(before); +}); + +test("agent recovery preserves planner attribution and stable option identities", () => { + let input = recovery(); + input.message.author = { kind: "agent" }; + let result = terminalPolicy(input); + expect(result.events.map(event => event.origin)).toEqual(["planner", "planner"]); + expect(result.events.map(event => event.actor)).toEqual([{ kind: "agent" }, { kind: "agent" }]); + expect(terminalPolicy(input)).toEqual(result); +}); + +test("event cap rejection removes every staged recovery event", () => { + let input = recovery(); + let opening = input.state.events[0]!; + input.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index) => ({ + ...opening, + id: `historical-${index}`, + })); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct option recovery rejected"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(input).toEqual(before); +}); + +test("contribution cap rejection removes an earlier staged addition", () => { + let input = recovery(); + let candidate = input.candidates[0]!; + for (let index = 0; index < 63; index++) { + let thread = input.state.threads[0]!; + input.state = applyInference(input.state, { + id: `reason-${index}`, + type: "reason.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: input.message.ts, + source: { + messageId: input.message.id, + author: { kind: "member", handle: "Mina" }, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "reason", + }, + contribution: { + id: `reason-contribution-${index}`, + text: candidate.quote, + authoring: "quoted", + }, + }, input.message); + } + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct option recovery rejected"); + expect(result.events).toEqual([]); + expect(input).toEqual(before); +}); diff --git a/apps/server/src/conversation-plan/policy-direct-recovery.ts b/apps/server/src/conversation-plan/policy-direct-recovery.ts new file mode 100644 index 00000000..05cede12 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-recovery.ts @@ -0,0 +1,125 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { DirectFacts, PolicyContext } from "./policy-context"; +import { optionIdFor, stableId } from "./policy-identity"; +import { choice } from "./policy-scoring"; +import type { Event, PolicyResult, State } from "./policy-types"; +import type { QuoteCandidate } from "./quotes"; + +export function createQuotedOption(context: PolicyContext) { + let { channelId, message } = context; + let quotedOption = ( + candidate: QuoteCandidate, + index: number, + threadId: string, + state: State, + ): Event => ({ + id: stableId(channelId, message.id, index, "option.added"), + type: "option.added", + threadId, + observedThreadVersion: state.threads.find(item => item.id === threadId)?.version ?? 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option", + }, + contribution: { + id: optionIdFor(channelId, message.id, index, message.ts), + text: candidate.quote, + authoring: "quoted", + targetId: threadId, + }, + }); + + return quotedOption; +} + +export function directRecovery( + context: PolicyContext, + facts: DirectFacts, + quotedOption: ReturnType, +): PolicyResult | undefined { + let { input, message, first, events, reviews } = context; + let { exactDirectAlternatives, matchingThreads, directBounds, optionLabels } = facts; + if (exactDirectAlternatives && matchingThreads.length) { + let thread = matchingThreads.length === 1 ? matchingThreads[0] : undefined; + let existing = new Set( + thread?.contributions.filter(item => item.kind === "option") + .map(item => item.text.trim().replace(/\s+/g, " ").toLowerCase()), + ); + let staged = input.state; + let additions: Event[] = []; + let recoveryOutcomes: PolicyResult["outcomes"] = []; + let target = choice(first, "thread_target"); + if ( + thread && directBounds && (target === thread.id || target === "new") + && choice(first, "act") === "question" + ) { + for (let [index, candidate] of input.candidates.entries()) { + let label = optionLabels[index]!; + if (existing.has(label)) { + recoveryOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "option already exists", + eventIds: [], + }); + continue; + } + let added = quotedOption(candidate, index, thread.id, staged); + try { + staged = applyInference(staged, added, message); + } catch { + return { + events: [], + candidates: reviews, + outcomes: input.candidates.map(item => ({ + start: item.start, + end: item.end, + status: "review", + gate: "direct option recovery rejected", + eventIds: [], + })), + policyGate: "direct option recovery rejected", + }; + } + existing.add(label); + additions.push(added); + recoveryOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [added.id], + targetId: thread.id, + }); + } + return { + events: additions, + selectedTarget: thread.id, + candidates: reviews, + outcomes: recoveryOutcomes, + policyGate: additions.length ? "direct options recovered" : "question already complete", + }; + } + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "question recovery unclear", + eventIds: [], + })), + policyGate: "question recovery unclear", + }; + } +} diff --git a/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts b/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts new file mode 100644 index 00000000..9b36d0e5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts @@ -0,0 +1,52 @@ +import { createPolicyContext } from "./policy-context"; +import { addMatchingThread, follow, inputFor } from "./policy-initial.test-fixtures"; +import { supportInput } from "./policy-candidate-application.test-fixtures"; +import { directAlternativeQuotes, extractQuotes } from "./quotes"; +import type { PolicyInput } from "./policy-types"; + +// Offline completion fixtures for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts1729–1769 and original candidate loop control. Original callbacks remain assigned whole. +export function fallbackInput(): PolicyInput { + let input = inputFor("Should we use Alpha or Alpha?"); + input.candidates = directAlternativeQuotes(input.message.text).map(quote => ({ + ...quote, + answers: follow({ role: "question", thread: "new" }), + })); + input.first.c0_owned_unretracted = { type: "noul", noul: 0.69 }; + return input; +} +export function finalContext(input = fallbackInput()) { + let context = createPolicyContext(input); + context.directQuestion = true; + context.outcomes = input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "no useful role", + eventIds: [], + })); + return context; +} + +export function multipleSupportInput(): PolicyInput { + let input = supportInput(["Jules"]); + let answers = input.candidates[0]!.answers; + input.message.text = Array.from( + { length: 4 }, + (_, index) => `I support Alpha for reason ${index}.`, + ).join(" "); + input.candidates = extractQuotes(input.message.text).map(quote => ({ + ...quote, + answers: structuredClone(answers), + })); + for (let index = 0; index < 4; index++) { + input.first[`c${index}_owned_unretracted`] = { type: "noul", noul: 0.95 }; + } + return input; +} + +export function recoveryInput(): PolicyInput { + let input = inputFor("Should audit logs go in PostgreSQL or object storage?"); + input.state = addMatchingThread(input, "audit"); + return input; +} diff --git a/apps/server/src/conversation-plan/policy-dispatcher.test.ts b/apps/server/src/conversation-plan/policy-dispatcher.test.ts new file mode 100644 index 00000000..e8a06fa1 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-dispatcher.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { + fallbackInput, + multipleSupportInput, + recoveryInput, +} from "./policy-dispatcher.test-fixtures"; +import { supportInput } from "./policy-candidate-application.test-fixtures"; +import { contributionInput } from "./policy-candidate-contribution.test-fixtures"; +import { ordinaryInput } from "./policy-candidate-ordinary.test-fixtures"; +import { reopeningInput, resolutionInput } from "./policy-candidate-resolution.test-fixtures"; +import { newChoiceInput, scopedInput } from "./policy-candidate-scoped.test-fixtures"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { confidentChoice, inputFor } from "./policy-initial.test-fixtures"; +import { replay } from "./domain"; +import { d01RecordedOpening, declarativeInput } from "./policy-terminal.test-fixtures"; +import { verificationInput } from "./policy-candidate-verification.test-fixtures"; + +// Complete pure dispatcher regressions; no runtime callers or original callback replacements. +test.each( + [ + ["reason", () => contributionInput(), "reason.added"], + ["option", () => contributionInput("option"), "option.added"], + ["constraint", () => contributionInput("constraint"), "constraint.added"], + ["support", () => supportInput([], true), "stance.changed"], + ["objection", () => stanceInput("objection"), "stance.changed"], + ["withdrawal", () => ordinaryInput(), "stance.changed"], + ["none", () => ordinaryInput("none"), "settle.agreed"], + ["question", () => ordinaryInput("question"), "thread.opened"], + ["resolution", () => resolutionInput(), "settle.suggested"], + ["reopening", () => reopeningInput(), "candidate.proposed"], + ] as const, +)("complete matching-role route %s applies %s", (_role, make, type) => { + let input = make(), before = structuredClone(input); + let result = planEvents(input); + expect(result.events[0]!.type).toBe(type); + expect(result.outcomes[0]!.status).toBe(type === "candidate.proposed" ? "review" : "accepted"); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); +test.each([[false, "scoped-choice.proposed"], [true, "scoped-choice.agreed"]] as const)( + "scoped path %s handles before ordinary application", + (assent, type) => { + let input = scopedInput(assent), result = planEvents(input); + expect(result.events.map(item => item.type)).toEqual([type]); + expect(result.outcomes[0]!.status).toBe("accepted"); + }, +); +test("direct new choice handles before ordinary role proposals", () => { + let result = planEvents(newChoiceInput()); + expect(result.events.map(item => item.type)).toEqual(["option.added", "settle.suggested"]); +}); +test("fallback opens from the later owned candidate without changing index-zero IDs", () => { + let result = planEvents(fallbackInput()); + expect(result.events.map(item => item.type)).toEqual(["thread.opened"]); + expect(result.outcomes[0]!.status).toBe("ignored"); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); +}); +test("truthy unrecognized role produces no proposal without throwing", () => { + let input = contributionInput(); + input.candidates[0]!.answers.role = confidentChoice("toString"); + let result = planEvents(input); + expect(result.events).toEqual([]); + expect(result.outcomes).toHaveLength(1); +}); +test("ineligible terminal result omits the ordinary selectedTarget property", () => { + let input = inputFor("Alpha or Beta?"); + input.message.streaming = true; + let result = planEvents(input); + expect(result.policyGate).toBe("ineligible message"); + expect(Object.hasOwn(result, "selectedTarget")).toBe(false); +}); +test("quote budget still precedes eligibility", () => { + let input = inputFor("Hi"); + input.message.streaming = true; + input.candidates = Array.from({ length: 13 }, () => input.candidates[0]!); + expect(() => planEvents(input)).toThrow(); +}); +test("candidate source skip preserves later processing and captured candidate order", () => { + let input = contributionInput(); + input.candidates = [{ ...input.candidates[0]!, quote: "wrong source" }, input.candidates[0]!]; + input.first.c1_owned_unretracted = { type: "noul", noul: 0.95 }; + let result = planEvents(input); + expect(result.outcomes).toHaveLength(2); + expect(result.outcomes[0]!.eventIds).toEqual([]); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(result.events.map(item => item.type)).toEqual(["reason.added"]); +}); + +test("multiple support candidates preserve order and share the first leaning transition", () => { + let input = multipleSupportInput(); + expect(input.candidates).toHaveLength(4); + let result = planEvents(input); + expect(result.events).toHaveLength(5); + expect(result.outcomes).toHaveLength(4); + expect(result.events[1]!.type).toBe("thread.leaning"); + expect(result.outcomes.every(item => item.status === "accepted")).toBe(true); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); + +test.each( + [ + [ + "owned compound", + () => inputFor("We need to choose how people sign in and how agents get credentials."), + "explicit compound questions accepted", + ], + [ + "attributed compound", + () => + inputFor("Alice said we need to choose how people sign in and how agents get credentials."), + "compound decision attribution unclear", + ], + ["recovery", recoveryInput, "direct options recovered"], + [ + "direct alternatives", + () => inputFor("Should audit logs go in PostgreSQL or object storage?"), + "direct alternatives accepted", + ], + ["declarative pair", declarativeInput, "declarative options accepted"], + ["purpose and question", d01RecordedOpening, "owned purpose and final question accepted"], + ] as const, +)("complete terminal path %s retains %s", (_name, make, gate) => { + let input = make(), before = structuredClone(input), result = planEvents(input); + expect(result.policyGate).toBe(gate); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); + +test("verified deferred proposal resumes before later scoped and ordinary handlers", () => { + let input = verificationInput(), before = structuredClone(input), result = planEvents(input); + expect(result.events.map(item => item.type)).toEqual(["settle.resumed"]); + expect(result.outcomes[0]!.status).toBe("accepted"); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); diff --git a/apps/server/src/conversation-plan/policy-explicit-list.test.ts b/apps/server/src/conversation-plan/policy-explicit-list.test.ts new file mode 100644 index 00000000..a95badcd --- /dev/null +++ b/apps/server/src/conversation-plan/policy-explicit-list.test.ts @@ -0,0 +1,256 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; +import { planEvents } from "./policy"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { MAX_EVENTS } from "./validation"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("D01's bare editor list adds four exact options to its uniquely open thread", () => { + let opening = d01RecordedOpening(); + let opened = planEvents(opening).events[0]!; + let state = applyInference(opening.state, opened, opening.message); + let threadId = state.threads[0]!.id; + let current = message( + "d01-m2", + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Nia", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "Tiptap", start: 0, end: 6 }, + { quote: "bare ProseMirror", start: 8, end: 24 }, + { quote: "Lexical", start: 26, end: 33 }, + { + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + }, + ]); + let triage = buildTriageRequest(current, [], state.threads, quotes); + expect(triage.questions.c3_owned_unretracted?.type).toBe("noul"); + let fourth = buildCandidateTargetingRequest(current, [], state.threads, quotes, 3); + expect(fourth.questions.c3_role?.type).toBe("choice"); + expect((fourth.state as { current: { text: string } }).current.text).toBe(quotes[3]!.quote); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ + new_option: 0.95, + c3_owned_unretracted: 0.95, + }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }); + expect(output.events.map(event => event.type)).toEqual(Array(4).fill("option.added")); + expect(output.events.map(event => event.type === "option.added" && event.threadId)) + .toEqual(Array(4).fill(threadId)); + expect(output.events.map(event => event.type === "option.added" && event.contribution.text)) + .toEqual(quotes.map(quote => quote.quote)); + expect(output.events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, messageId: current.id, role: "option" })), + ); + expect( + new Set(output.events.map(event => event.type === "option.added" && event.contribution.id)) + .size, + ).toBe(4); +}); +test("D01's bare list cannot create options without one identifiable open thread", () => { + let opening = d01RecordedOpening(); + let editor = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let another = message("other-question", "Which database should we use?", "Nia"); + let ambiguous = applyInference(editor, { + id: "other-thread-event", + type: "thread.opened", + threadId: "other-thread", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: another.ts, + source: { + messageId: another.id, + author: another.author as ConversationPlan.SourceAuthor, + quote: another.text, + start: 0, + end: another.text.length, + role: "question", + }, + question: another.text, + }, another); + let current = message( + "d01-m2-untargeted", + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Nia", + ); + let spans = [[0, 6], [8, 24], [26, 33], [38, 97]]; + let candidates: PolicyInput["candidates"] = spans.map(([start, end]) => ({ + quote: current.text.slice(start!, end!), + start: start!, + end: end!, + answers: { + ...follow({ role: "option", thread: "none", threadProbability: 0.55 }), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })); + let firstPass = { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: { + type: "choice" as const, + choice: "none", + confidence: 0.5, + probabilities: { none: 0.5, [editor.threads[0]!.id]: 0.25, "other-thread": 0.25 }, + }, + }; + for (let state of [initialState(), ambiguous]) { + expect( + planEvents({ + channelId: "channel", + message: current, + state, + first: firstPass, + candidates, + }).events, + ).toEqual([]); + } +}); + +test("an unrelated four-option list requires every option to be well supported", () => { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let threadId = state.threads[0]!.id; + state.threads[0]!.question = "Which launch queue should we use?"; + let current = message( + "queue-options", + "Redis, SQS, Postgres, or a small in-process queue with disk replay.", + "Nia", + ); + let quotes = extractQuotes(current.text); + let input: PolicyInput = { + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }; + expect(planEvents(input).events.map(event => event.type)).toEqual(Array(4).fill("option.added")); + let three = message("queue-options-three", "Redis, SQS, or Postgres.", "Nia"); + let threeQuotes = extractQuotes(three.text); + expect(threeQuotes).toHaveLength(3); + expect( + planEvents({ + ...input, + message: three, + candidates: threeQuotes.map((quote, index) => ({ + ...quote, + answers: input.candidates[index]!.answers, + })), + }).events.map(event => event.type), + ).toEqual(Array(3).fill("option.added")); + for ( + let [source, candidates] of [ + [current, input.candidates.slice(0, 2)], + [three, input.candidates.slice(0, 1)], + ] as const + ) { + let output = planEvents({ ...input, message: source, candidates }); + expect(output.events).toEqual([]); + expect(output.outcomes).toHaveLength(candidates.length); + expect(output.outcomes.every(outcome => outcome.status === "review")).toBe(true); + } + let capped = structuredClone(input); + let openingEvent = capped.state.events[0]!; + capped.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index) => ({ + ...openingEvent, + id: `historical-${index}`, + })); + expect(planEvents(capped).events).toEqual([]); + input.candidates[1]!.answers.duplicate = { type: "noul", noul: 0.9 }; + expect(planEvents(input).events).toEqual([]); +}); + +test("an explicit list near the contribution limit does not add only its first option", () => { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let thread = state.threads[0]!; + let seed = message("prior-options", "An earlier alternative.", "Nia"); + for (let index = 0; index < 63; index++) { + state = applyInference(state, { + id: `prior-option-event-${index}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + source: { + messageId: seed.id, + author: seed.author as ConversationPlan.SourceAuthor, + quote: seed.text, + start: 0, + end: seed.text.length, + role: "option", + }, + contribution: { + id: `prior-contribution-${index}`, + text: seed.text, + authoring: "quoted", + }, + }, seed); + } + let current = message("capacity-list", "Redis, SQS, Postgres, or SQLite.", "Nia"); + let quotes = extractQuotes(current.text); + let input: PolicyInput = { + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(thread.id), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: thread.id }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }; + let output = planEvents(input); + expect(output.events).toEqual([]); + expect(output.outcomes).toHaveLength(4); + expect(output.outcomes.every(outcome => outcome.status === "review")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/policy-final.test.ts b/apps/server/src/conversation-plan/policy-final.test.ts new file mode 100644 index 00000000..08c07e3e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-final.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { finishPolicy } from "./policy-final"; +import { finalContext } from "./policy-dispatcher.test-fixtures"; +import { stableId } from "./policy-identity"; +import { inputFor } from "./policy-initial.test-fixtures"; + +test("final fallback owns candidate one but uses index-zero identity and the full message", () => { + let context = finalContext(), before = context.working; + let result = finishPolicy(context); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ + id: stableId(context.channelId, context.message.id, 0, "thread.opened"), + question: context.message.text, + source: { quote: context.message.text, start: 0, end: context.message.text.length }, + }); + expect(result.outcomes[0]!.eventIds).toEqual([]); + expect(result.outcomes[1]!.eventIds).toEqual([result.events[0]!.id]); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(context.working).toBe(before); + expect(context.working.threads).toHaveLength(0); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); + expect(result.selectedTarget).toBeUndefined(); +}); +test("fallback reads live owned scores and role answers", () => { + let context = finalContext(); + context.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + expect(finishPolicy(context).events).toEqual([]); + context.first.c1_owned_unretracted = { type: "noul", noul: 0.7 }; + context.input.candidates[1]!.answers.role = { + type: "choice", + choice: "none", + confidence: 1, + probabilities: { none: 1 }, + }; + expect(finishPolicy(context).events).toEqual([]); +}); +test("final gate truncates at exactly three hundred characters", () => { + let context = finalContext(); + context.directQuestion = false; + context.outcomes[0]!.gate = "x".repeat(299); + context.outcomes[1]!.gate = "later"; + let result = finishPolicy(context); + expect(result.policyGate).toBe(("0:" + "x".repeat(299) + "; 1:later").slice(0, 300)); + expect(result.policyGate).toHaveLength(300); +}); +test("final empty result preserves selectedTarget property presence", () => { + let input = inputFor("No source candidates."); + input.candidates = []; + let context = finalContext(input); + context.directQuestion = false; + let result = finishPolicy(context); + expect(result).toEqual({ + events: [], + selectedTarget: undefined, + candidates: [], + outcomes: [], + policyGate: "no source candidates", + }); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/policy-final.ts b/apps/server/src/conversation-plan/policy-final.ts new file mode 100644 index 00000000..8ff4a4c4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-final.ts @@ -0,0 +1,64 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; + +/** Complete only after the original loop exits; fallback validation retains working unchanged. */ +export function finishPolicy(context: PolicyContext): PolicyResult { + let { + input, + channelId, + message, + first, + events, + reviews, + outcomes, + working, + selectedTarget, + directQuestion, + } = context; + if (directQuestion && !events.length) { + let ownedQuestion = input.candidates.findIndex((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && roleChoice(candidate.answers, first) === "question" + ); + if (ownedQuestion >= 0) { + let threadId = `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + applyInference(working, opening, message); + events.push(opening); + outcomes[ownedQuestion]!.eventIds.push(opening.id); + outcomes[ownedQuestion]!.status = "accepted"; + outcomes[ownedQuestion]!.gate = "accepted"; + } catch { + outcomes[ownedQuestion]!.status = "review"; + outcomes[ownedQuestion]!.gate = "question could not be opened"; + } + } + } + let policyGate = outcomes.length + ? outcomes.map((outcome, index) => `${index}:${outcome.gate}`).join("; ").slice(0, 300) + : "no source candidates"; + return { events, selectedTarget, candidates: reviews, outcomes, policyGate }; +} diff --git a/apps/server/src/conversation-plan/policy-identity.test.ts b/apps/server/src/conversation-plan/policy-identity.test.ts new file mode 100644 index 00000000..ccb768b1 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-identity.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { optionIdFor } from "./policy-identity"; + +// Exact callback: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +// Remaining pipeline callbacks and the D19 policy callback await the complete policy. + +test("classifier option IDs are deterministic ULIDs using the message's Unix seconds", () => { + let id = optionIdFor("channel", "m1", 0, 1_000); + expect(id).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/); + expect(optionIdFor("channel", "m1", 0, 1_000)).toBe(id); + expect(optionIdFor("channel", "m1", 1, 1_000)).not.toBe(id); + let alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let milliseconds = 0; + for (let character of id.slice(0, 10)) { + milliseconds = milliseconds * 32 + alphabet.indexOf(character); + } + expect(milliseconds).toBe(1_000_000); +}); diff --git a/apps/server/src/conversation-plan/policy-identity.ts b/apps/server/src/conversation-plan/policy-identity.ts new file mode 100644 index 00000000..02eef6c4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-identity.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { QUESTION_SET_VERSION } from "./question-shared"; + +export function stableId( + channelId: string, + messageId: string, + index: number, + kind: string, +): string { + let digest = createHash("sha256").update(JSON.stringify([ + channelId, + messageId, + index, + kind, + QUESTION_SET_VERSION, + ])).digest("hex").slice(0, 24); + return `classifier:${digest}`; +} + +export const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** Card option identity uses the message time and deterministic message-derived randomness. */ +export function optionIdFor( + channelId: string, + messageId: string, + index: number, + at: number, +): string { + let digest = createHash("sha256").update(JSON.stringify([ + channelId, + messageId, + index, + "option", + QUESTION_SET_VERSION, + ])).digest(); + let time = ""; + let milliseconds = Math.max(0, Math.floor(at * 1_000)); + for (let position = 0; position < 10; position++) { + time = CROCKFORD[milliseconds % 32] + time; + milliseconds = Math.floor(milliseconds / 32); + } + let random = ""; + for (let position = 0; position < 16; position++) { + random += CROCKFORD[digest[position]! % 32]; + } + return time + random; +} diff --git a/apps/server/src/conversation-plan/policy-initial-terminals.test.ts b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts new file mode 100644 index 00000000..6804e742 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createPolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { inputFor, prefix, terminalPolicy } from "./policy-initial.test-fixtures"; +import { MAX_EVENTS } from "./validation"; +import type { Event } from "./policy-types"; + +let compound = "We need to choose how people sign in and how agents get credentials."; + +test("quote budget rejects more than four candidates", () => { + let input = inputFor(compound); + input.candidates = Array.from({ length: 5 }, () => input.candidates[0]!); + expect(() => createPolicyContext(input)).toThrow("source quote count exceeds 4"); +}); + +test.each(["streaming", "empty", "system"] as const)( + "ineligible %s returns no events", + kind => { + let input = inputFor(compound); + if (kind === "streaming") input.message.streaming = true; + if (kind === "empty") input.message.text = ""; + if (kind === "system") input.message.author = { kind: "system" }; + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toEqual({ + events: [], + candidates: [], + outcomes: [], + policyGate: "ineligible message", + }); + expect(context.facts).toBeUndefined(); + }, +); + +test("attributed compound questions return review without opening threads", () => { + let input = inputFor( + "Alice said we need to choose how people sign in and how agents get credentials.", + ); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("compound decision attribution unclear"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(input).toEqual(before); +}); + +test("owned compound questions stage two exact openings atomically", () => { + let input = inputFor(compound); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("explicit compound questions accepted"); + expect(result.events.map(event => event.type)).toEqual(["thread.opened", "thread.opened"]); + expect(result.events.map(event => "source" in event ? event.source : undefined)).toEqual( + input.candidates.map(candidate => ({ + messageId: input.message.id, + author: input.message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "question", + })), + ); + expect(result.selectedTarget).toBe(result.events[0]!.threadId); + expect(result.outcomes.map(outcome => outcome.eventIds)).toEqual( + result.events.map(event => [event.id]), + ); + expect(input).toEqual(before); +}); + +test("second compound opening failure discards the staged first opening and continues", () => { + let input = inputFor(compound); + // The event cap is exercised without inventing thousands of domain transitions. + input.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index): Event => ({ + id: `historical-${index}`, + type: "thread.opened", + threadId: `historical-thread-${index}`, + observedThreadVersion: 0, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 999, + question: "Historical question?", + })); + let before = structuredClone(input); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(context.events).toEqual([]); + expect(context.working).toBe(input.state); + expect(input).toEqual(before); +}); + +test("eligible unhandled inputs expose facts and explicitly continue", () => { + let input = inputFor("Useful chatter."); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(context.facts?.exactDirectAlternatives).toBe(false); + expect(context.events).toEqual([]); + expect(prefix(input)).toBeUndefined(); +}); + +test("continuation retains the quoted-option closure from the original message capture", () => { + let input = inputFor("Should audit logs go in PostgreSQL or object storage?"); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + context.message = { ...context.message, id: "changed-after-terminal-pass" }; + let option = context.quotedOption!(input.candidates[0]!, 0, "audit", input.state); + expect(option.type).toBe("option.added"); + expect("source" in option && option.source?.messageId).toBe(input.message.id); +}); diff --git a/apps/server/src/conversation-plan/policy-initial-terminals.ts b/apps/server/src/conversation-plan/policy-initial-terminals.ts new file mode 100644 index 00000000..6c8d9125 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.ts @@ -0,0 +1,28 @@ +import { + compoundAttribution, + compoundOpenings, + questionListCorroboration, +} from "./policy-compound"; +import { directFacts, type PolicyContext } from "./policy-context"; +import { createQuotedOption, directRecovery } from "./policy-direct-recovery"; +import type { PolicyResult } from "./policy-types"; + +/** Requires createPolicyContext first. Undefined continues beyond these four terminal checks. */ +export function runInitialTerminals(context: PolicyContext): PolicyResult | undefined { + let { message, events, reviews, outcomes } = context; + if (message.streaming || !message.text || message.author.kind === "system") { + return { events, candidates: reviews, outcomes, policyGate: "ineligible message" }; + } + + let facts = directFacts(context); + context.facts = facts; + let result = questionListCorroboration(context, facts); + if (result) return result; + let quotedOption = createQuotedOption(context); + context.quotedOption = quotedOption; + result = compoundAttribution(context); + if (result) return result; + result = compoundOpenings(context); + if (result) return result; + return directRecovery(context, facts, quotedOption); +} diff --git a/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts b/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts new file mode 100644 index 00000000..a2a149db --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts @@ -0,0 +1,157 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { createPolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import type { PolicyInput, PolicyResult } from "./policy-types"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +export let confidentChoice = (choice: string): any => ({ + type: "choice", + choice, + confidence: 0.95, + probabilities: { [choice]: 0.95, ...(choice === "none" ? { other: 0.05 } : { none: 0.05 }) }, +}); +export let member = (handle: string): Extract => ({ + kind: "member", + handle, +}); +export let message = (id: string, text: string, handle = "Mina"): Chat.Entry => ({ + id, + text, + author: member(handle), + ts: 1000, +}); +export function first(nouls: Record = {}): Record { + return Object.fromEntries( + Object.entries({ + c0_owned_unretracted: 0.95, + c1_owned_unretracted: 0.95, + c2_owned_unretracted: 0.95, + ...nouls, + }).map(([key, noul]) => [key, { type: "noul", noul }]), + ); +} +export function follow( + options: { + role: string; + thread: string; + threadProbability?: number; + explicit_resolution?: number; + reopening?: number; + material_objection?: number; + }, +): Record { + let p = options.threadProbability ?? 0.95; + return { + role: { + type: "choice", + choice: options.role, + confidence: p, + probabilities: options.role === "none" + ? { none: p, support: 1 - p } + : { [options.role]: p, none: 1 - p }, + }, + thread: { + type: "choice", + choice: options.thread, + confidence: p, + probabilities: { [options.thread]: p, none: 1 - p }, + }, + explicit_resolution: { type: "noul", noul: options.explicit_resolution ?? 0 }, + reopening: { type: "noul", noul: options.reopening ?? 0 }, + material_objection: { type: "noul", noul: options.material_objection ?? 0 }, + new_option: { type: "noul", noul: options.role === "option" ? 0.95 : 0.05 }, + planning_substance: { + type: "noul", + noul: ["reason", "constraint"].includes(options.role) ? 0.95 : 0.05, + }, + support: { type: "noul", noul: options.role === "support" ? 0.95 : 0.05 }, + objection: { type: "noul", noul: options.role === "objection" ? 0.95 : 0.05 }, + }; +} + +export function prefix(input: PolicyInput): PolicyResult | undefined { + return runInitialTerminals(createPolicyContext(input)); +} + +// Original callbacks call planEvents; this test adapter requires one of the extracted terminals. +export function terminalPolicy(input: PolicyInput): PolicyResult { + let result = prefix(input); + expect(result).toBeDefined(); + if (!result) throw new Error("fixture continues beyond initial policy terminals"); + return result; +} + +export function inputFor(text: string): PolicyInput { + let current = message("initial-policy", text); + return { + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.95, enough_purpose: 0.95 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + }, + candidates: extractQuotes(text).map(quote => ({ + ...quote, + answers: { + ...follow({ role: "question", thread: "new" }), + duplicate: { type: "noul", noul: 0 }, + }, + })), + }; +} + +export function addMatchingThread(input: PolicyInput, threadId: string): ConversationPlan.State { + let original = { ...input.message, id: `original-${threadId}` }; + return applyInference(input.state, { + id: `opening-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: original.ts, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: original.text, + start: 0, + end: original.text.length, + role: "question", + }, + question: original.text, + }, original); +} + +export function seedOptions(input: PolicyInput, count: number): void { + for (let [index, candidate] of input.candidates.slice(0, count).entries()) { + let thread = input.state.threads[0]!; + input.state = applyInference(input.state, { + id: `prior-option-${index}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: input.message.ts, + source: { + messageId: input.message.id, + author: input.message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option", + }, + contribution: { + id: `prior-contribution-${index}`, + text: candidate.quote, + authoring: "quoted", + }, + }, input.message); + } +} diff --git a/apps/server/src/conversation-plan/policy-proposal.ts b/apps/server/src/conversation-plan/policy-proposal.ts new file mode 100644 index 00000000..68876baf --- /dev/null +++ b/apps/server/src/conversation-plan/policy-proposal.ts @@ -0,0 +1,36 @@ +import { runWithdrawal } from "./policy-candidate-withdrawal"; +import { runAgreement } from "./policy-candidate-agreement"; +import { runQuestion } from "./policy-candidate-question"; +import { runContribution } from "./policy-candidate-contribution"; +import { runStance } from "./policy-candidate-stance"; +import { runResolution } from "./policy-candidate-resolution"; +import { runReopening } from "./policy-candidate-reopening"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; + +/** Private role routing preserves handled skips separately from a missing proposal. */ +export function proposeCandidate( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { role } = frame; + if (role === "withdrawal") { + return runWithdrawal(context, entry, frame); + } else if (role === "none") { + return runAgreement(context, entry, frame); + } else if (role === "question") { + return runQuestion(context, entry, frame); + } else if (["option", "reason", "constraint"].includes(role)) { + return runContribution(context, entry, frame); + } else if (role === "support" || role === "objection") { + return runStance(context, entry, frame); + } else if (role === "resolution") { + return runResolution(context, entry, frame); + } else if (role === "reopening") { + return runReopening(context, entry, frame); + } + return { proposed: undefined }; +} diff --git a/apps/server/src/conversation-plan/policy-purpose-question.ts b/apps/server/src/conversation-plan/policy-purpose-question.ts new file mode 100644 index 00000000..d2e014c5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-purpose-question.ts @@ -0,0 +1,100 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; + +export function purposeAndQuestion(context: PolicyContext): PolicyResult | undefined { + let { input, channelId, message, first, reviews } = context; + let [purpose, finalQuestion] = input.candidates; + let finalTarget = finalQuestion?.answers.thread; + let newProbability = finalTarget?.type === "choice" + ? finalTarget.probabilities.new ?? 0 + : 0; + let competingProbability = finalTarget?.type === "choice" + ? Math.max( + 0, + ...Object.entries(finalTarget.probabilities) + .filter(([key]) => key !== "new").map(([, probability]) => probability), + ) + : 0; + let purposeRole = purpose && roleChoice(purpose.answers, first); + let purposeUsefulProbability = purpose?.answers.role?.type === "choice" + ? Math.max( + 0, + ...Object.entries(purpose.answers.role.probabilities) + .filter(([role]) => role !== "none").map(([, probability]) => probability), + ) + : 1; + let purposeQuestion = message.author.kind === "member" + && input.state.threads.length === 0 + && message.text.length <= 500 && input.candidates.length === 2 + && purpose?.start === 0 && finalQuestion?.end === message.text.length + && purpose.end < finalQuestion.start + && /^\s+$/.test(message.text.slice(purpose.end, finalQuestion.start)) + && message.text.slice(purpose.start, purpose.end) === purpose.quote + && message.text.slice(finalQuestion.start, finalQuestion.end) === finalQuestion.quote + && /^\s*(?:(?:we|i)\s+)?need to\s+(?:pick|choose|select|decide on)\b[^?]{2,300}\.$/i + .test(purpose.quote) + && /^(?:what|which|how)\b[^.!?]{3,150}\?$/i.test(finalQuestion.quote) + && !/["“”‘’🙄]/u.test(message.text) + && !/\b(?:said|asked|told|claimed|reported|according to|if|unless|maybe|perhaps)\b/i + .test(message.text) + && noul(first, "new_question") >= 0.8 + && noul(first, "enough_purpose") >= 0.8 + && choice(first, "act") === "question" + && choice(first, "thread_target") === "new" + && noul(first, "c0_owned_unretracted") >= OWNED_QUOTE_MIN + && noul(first, "c1_owned_unretracted") >= OWNED_QUOTE_MIN + && (purposeRole === undefined || purposeRole === "none") + && purposeUsefulProbability < 0.8 + && purpose.answers.role?.type === "choice" + && (purpose.answers.role.probabilities.question ?? 0) < 0.2 + && roleChoice(finalQuestion.answers, first) === "question" + && finalQuestion.answers.role?.type === "choice" + && (finalQuestion.answers.role.probabilities.question ?? 0) >= 0.9 + && finalTarget?.type === "choice" && finalTarget.choice === "new" + && newProbability >= 0.6 && newProbability - competingProbability >= 0.2; + if (purposeQuestion) { + let threadId = `thread:${stableId(channelId, message.id, 1, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 1, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + applyInference(input.state, opening, message); + return { + events: [opening], + selectedTarget: threadId, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: index === 1 ? "accepted" : "ignored", + gate: index === 1 ? "accepted" : "opening purpose", + eventIds: index === 1 ? [opening.id] : [], + ...(index === 1 ? { targetId: threadId } : {}), + })), + policyGate: "owned purpose and final question accepted", + }; + } catch { + // Let the ordinary candidate gates report why the opening was rejected. + } + } +} diff --git a/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts b/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts new file mode 100644 index 00000000..fbdbcb51 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { inputFor } from "./policy-initial.test-fixtures"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { + capEvents, + d01RecordedOpening, + declarativeInput, + prepareRemaining, + remainingPrefix, + stateWithOption, + terminalPolicy, +} from "./policy-terminal.test-fixtures"; +import { MAX_EVENTS, MAX_THREADS } from "./validation"; + +let question = "Should audit logs go in PostgreSQL or object storage?"; + +test.each(["member", "agent"] as const)( + "direct alternatives retain exact options and working state for %s", + kind => { + let input = inputFor(question); + if (kind === "agent") input.message.author = { kind: "agent" }; + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct alternatives accepted"); + expect(result.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + ]); + expect(result.events.map(event => event.origin)).toEqual( + Array(3).fill(kind === "agent" ? "planner" : "classifier"), + ); + expect(result.outcomes.map(outcome => outcome.eventIds.length)).toEqual([2, 1]); + expect(context.working.events).toEqual(result.events); + expect(context.directQuestion).toBe(true); + expect(input).toEqual(before); + expect(terminalPolicy(input)).toEqual(result); + }, +); + +test("direct opening rejection preserves the original working reference", () => { + let input = inputFor(question); + let seed = stateWithOption("Unrelated question?", "seed", "seed-option", "Seed option."); + input.state.threads = Array.from( + { length: MAX_THREADS }, + (_, index) => ({ ...seed.threads[0]!, id: `seed-${index}` }), + ); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)?.policyGate).toBe("direct question rejected"); + expect(context.working).toBe(input.state); +}); + +test("direct option rejection retains successful opening and earlier addition in working", () => { + let input = inputFor(question); + capEvents(input, MAX_EVENTS - 2); + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct option rejected"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(context.working.events).toHaveLength(MAX_EVENTS); + expect(context.working.threads[0]?.contributions).toHaveLength(1); + expect(context.working).not.toBe(input.state); + expect(input).toEqual(before); +}); + +test("quoted-option errors propagate while direct working retains earlier successes", () => { + let input = inputFor(question); + let context = prepareRemaining(input); + let original = context.quotedOption!; + context.quotedOption = (...args) => { + if (args[1] === 1) throw new Error("quoted option failure"); + return original(...args); + }; + expect(() => runRemainingTerminals(context)).toThrow("quoted option failure"); + expect(context.working.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + ]); + expect(input.state.events).toEqual([]); +}); + +test("declarative batch rejection leaves staged state local", () => { + let input = declarativeInput(); + capEvents(input, MAX_EVENTS - 1); + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("declarative options rejected"); + expect(result.events).toEqual([]); + expect(context.working).toBe(input.state); + expect(input).toEqual(before); +}); + +test("declarative quoted-option errors stay outside inference catches", () => { + let input = declarativeInput(); + let context = prepareRemaining(input); + let original = context.quotedOption!; + context.quotedOption = (...args) => { + if (args[1] === 1) throw new Error("quoted option failure"); + return original(...args); + }; + expect(() => runRemainingTerminals(context)).toThrow("quoted option failure"); + expect(context.working).toBe(input.state); +}); + +test("purpose acceptance validates without replacing context working", () => { + let input = d01RecordedOpening(); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)?.policyGate).toBe( + "owned purpose and final question accepted", + ); + expect(context.working).toBe(input.state); + expect(input.state.threads).toEqual([]); +}); + +test("purpose opening rejection continues without assigning working", () => { + let input = d01RecordedOpening(); + capEvents(input, MAX_EVENTS); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)).toBeUndefined(); + expect(context.working).toBe(input.state); + expect(context.directQuestion).toBe(false); + expect(remainingPrefix(input)).toBeUndefined(); +}); + +test("directQuestion survives continuation when bounds fail and later inputs change", () => { + let input = inputFor("Should audit logs go in PostgreSQL or PostgreSQL?"); + let context = prepareRemaining(input); + expect(context.facts?.exactDirectAlternatives).toBe(true); + expect(context.facts?.directBounds).toBe(false); + expect(runRemainingTerminals(context)).toBeUndefined(); + expect(context.directQuestion).toBe(true); + context.working = stateWithOption("Other question?", "other", "other-option", "Other option."); + context.first.new_question = { type: "noul", noul: 0 }; + context.facts!.exactDirectAlternatives = false; + expect(context.directQuestion).toBe(true); + expect(remainingPrefix(input)).toBeUndefined(); +}); + +test("remaining stages and cached options use the initially captured message", () => { + let input = inputFor(question); + let original = input.message; + let context = prepareRemaining(input); + input.message = { + ...input.message, + id: "changed-message", + text: "Changed after initialization.", + }; + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct alternatives accepted"); + expect(result.events.map(event => "source" in event ? event.source?.messageId : undefined)) + .toEqual(Array(3).fill(original.id)); + expect(result.events[0]).toMatchObject({ question: original.text }); +}); diff --git a/apps/server/src/conversation-plan/policy-remaining-terminals.ts b/apps/server/src/conversation-plan/policy-remaining-terminals.ts new file mode 100644 index 00000000..c83f8f27 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.ts @@ -0,0 +1,22 @@ +import type { PolicyContext } from "./policy-context"; +import { declarativePair } from "./policy-declarative"; +import { directAlternatives } from "./policy-direct-alternatives"; +import { purposeAndQuestion } from "./policy-purpose-question"; +import { choice, noul } from "./policy-scoring"; +import type { PolicyResult } from "./policy-types"; + +/** Run after initial terminals continue; facts and quotedOption retain their original captures. */ +export function runRemainingTerminals(context: PolicyContext): PolicyResult | undefined { + let { message, first } = context; + let { exactDirectAlternatives, matchingThreads } = context.facts!; + let directQuestion = exactDirectAlternatives && !matchingThreads.length + && message.text.length <= 500 && noul(first, "new_question") >= 0.9 + && choice(first, "act") === "question" && choice(first, "thread_target") === "new"; + + context.directQuestion = directQuestion; + let result = directAlternatives(context, directQuestion); + if (result) return result; + result = declarativePair(context); + if (result) return result; + return purposeAndQuestion(context); +} diff --git a/apps/server/src/conversation-plan/policy-scoring.ts b/apps/server/src/conversation-plan/policy-scoring.ts new file mode 100644 index 00000000..9e721c06 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-scoring.ts @@ -0,0 +1,62 @@ +import type { JevAnswer } from "./jev"; + +export function noul(answers: Record, key: string): number { + let answer = answers[key]; + return answer?.type === "noul" ? answer.noul : 0; +} +export function choice(answers: Record, key: string): string | undefined { + let answer = answers[key]; + if (answer?.type !== "choice") return; + let ranked = Object.values(answer.probabilities).sort((a, b) => b - a); + if ((ranked[0] ?? 0) < 0.8 || (ranked[0] ?? 0) - (ranked[1] ?? 0) < 0.2) return; + return answer.choice; +} +export function moderateChoice( + answers: Record, + key: string, + expected: string, + minimum: number, +): boolean { + let answer = answers[key]; + if (answer?.type !== "choice" || answer.choice !== expected || answer.confidence < 0.5) { + return false; + } + let probability = answer.probabilities[expected] ?? 0; + let other = Math.max( + 0, + ...Object.entries(answer.probabilities) + .filter(([name]) => name !== expected).map(([, value]) => value), + ); + return probability >= minimum && probability - other >= 0.2; +} +export function roleChoice( + answers: Record, + first: Record, + pendingAgreement = false, +): string | undefined { + let answer = answers.role; + if (answer?.type !== "choice") return; + let role = answer.choice; + if (["question", "resolution", "reopening", "none"].includes(role)) { + return choice(answers, "role"); + } + let significance = first.significance; + if (significance?.type === "score" && significance.score < 1) return; + let corroboration: Record = { + option: ["new_option", 0.75], + reason: ["planning_substance", 0.7], + constraint: ["planning_substance", 0.65], + support: ["support", 0.65], + objection: ["objection", 0.55], + }; + let requirement = corroboration[role]; + let strength = role === "support" && pendingAgreement + ? Math.max(noul(answers, "support"), noul(answers, "agrees_with_settle")) + : requirement + ? noul(answers, requirement[0]) + : 0; + if (!requirement || strength < requirement[1]) return; + let ranked = Object.values(answer.probabilities).sort((a, b) => b - a); + if ((ranked[0] ?? 0) < 0.55 || (ranked[0] ?? 0) - (ranked[1] ?? 0) < 0.1) return; + return role; +} diff --git a/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts b/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts new file mode 100644 index 00000000..1de3d6b4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts @@ -0,0 +1,177 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevAnswer } from "./jev"; +import { createPolicyContext, type PolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { confidentChoice, first, follow, inputFor, message } from "./policy-initial.test-fixtures"; +import type { Event, PolicyInput, PolicyResult } from "./policy-types"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +export function d01RecordedOpening(): PolicyInput { + let current = message( + "d01-m1", + "need to pick an editor before comments get any deeper. what are we comparing?", + "Nia", + ); + let quotes = extractQuotes(current.text); + let firstPass: Record = { + ...first({ + new_question: 0.88, + enough_purpose: 0.89, + c0_owned_unretracted: 0.93, + c1_owned_unretracted: 0.95, + }), + act: { + type: "choice" as const, + choice: "question", + confidence: 0.84, + probabilities: { question: 0.87, proposal: 0.13 }, + }, + thread_target: { + type: "choice" as const, + choice: "new", + confidence: 0.99, + probabilities: { new: 1, none: 0 }, + }, + }; + let candidates: PolicyInput["candidates"] = quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: index ? "question" : "resolution", thread: "new" }), + role: { + type: "choice" as const, + choice: index ? "question" : "resolution", + confidence: index ? 0.97 : 0.65, + probabilities: (index + ? { question: 0.98, none: 0.02 } + : { resolution: 0.68, question: 0.01, none: 0.31 }) as Record, + }, + thread: { + type: "choice" as const, + choice: "new", + confidence: index ? 0.32 : 0.65, + probabilities: index ? { new: 0.66, none: 0.34 } : { new: 0.83, none: 0.17 }, + }, + planning_substance: { type: "noul" as const, noul: index ? 0.15 : 0.85 }, + }, + })); + return { + channelId: "channel", + message: current, + state: initialState(), + first: firstPass, + candidates, + }; +} +export function stateWithOption( + questionText: string, + threadId: string, + optionId: string, + optionText: string, +): ConversationPlan.State { + return addThreadWithOption(initialState(), threadId, questionText, optionId, optionText); +} +export function addThreadWithOption( + state: ConversationPlan.State, + threadId: string, + questionText: string, + optionId: string, + optionText: string, +): ConversationPlan.State { + let questionMessage = message(`question-${threadId}`, questionText); + let withQuestion = applyInference(state, { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: questionMessage.ts, + source: { + messageId: questionMessage.id, + author: questionMessage.author as ConversationPlan.SourceAuthor, + quote: questionMessage.text, + start: 0, + end: questionMessage.text.length, + role: "question", + }, + question: questionMessage.text, + }, questionMessage); + let optionMessage = message(`option-${threadId}`, optionText); + return applyInference(withQuestion, { + id: `option-${threadId}`, + type: "option.added", + threadId, + observedThreadVersion: withQuestion.threads.find(item => item.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: optionMessage.ts, + source: { + messageId: optionMessage.id, + author: optionMessage.author as ConversationPlan.SourceAuthor, + quote: optionMessage.text, + start: 0, + end: optionMessage.text.length, + role: "option", + }, + contribution: { + id: optionId, + text: optionText, + authoring: "quoted", + targetId: threadId, + }, + }, optionMessage); +} + +export function remainingPrefix(input: PolicyInput): PolicyResult | undefined { + let context = createPolicyContext(input); + let initial = runInitialTerminals(context); + if (initial) return initial; + return runRemainingTerminals(context); +} + +// The retained callbacks need a terminal result, not a complete-policy implementation. +export function terminalPolicy(input: PolicyInput): PolicyResult { + let result = remainingPrefix(input); + expect(result).toBeDefined(); + if (!result) throw new Error("fixture continues beyond remaining policy terminals"); + return result; +} + +export function prepareRemaining(input: PolicyInput): PolicyContext { + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + return context; +} + +export function declarativeInput(): PolicyInput { + let input = inputFor("By agent access I mean Copilot or bring-your-own API keys."); + input.state = stateWithOption( + "How should we provide agent access?", + "access", + "gateway", + "Use a hosted gateway.", + ); + input.first.new_option = { type: "noul", noul: 0.95 }; + input.first.thread_target = confidentChoice("access"); + for (let candidate of input.candidates) candidate.answers.thread = confidentChoice("access"); + return input; +} + +// Exercise event counts without claiming that the synthetic history is replay-consistent. +export function capEvents(input: PolicyInput, count: number): void { + input.state.events = Array.from({ length: count }, (_, index): Event => ({ + id: `historical-${index}`, + type: "thread.opened", + threadId: `historical-thread-${index}`, + observedThreadVersion: 0, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 999, + question: "Historical question?", + })); +} diff --git a/apps/server/src/conversation-plan/policy-topic.test.ts b/apps/server/src/conversation-plan/policy-topic.test.ts new file mode 100644 index 00000000..c1dd5080 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-topic.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { directAlternativeQuotes, extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { planEvents } from "./policy"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("high-confidence option labels cannot turn D03's question or grouped list into choices", () => { + let text = "what sends transactional notifications? our SMTP relay, Postmark, or SES?"; + let current = message("d03-wrong-option-spans", text, "Liv"); + let quotes = [ + { quote: text.slice(0, 39), start: 0, end: 39 }, + { quote: text.slice(40, 73), start: 40, end: 73 }, + ]; + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); +test.each([ + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "When configured, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "As Fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay, Because, or SES?", + "what sends transactional notifications? our SMTP relay, When configured, or SES?", + "what sends transactional notifications? our SMTP relay, As Fallback, or SES?", + "what sends transactional notifications if cost wins? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications according to Dan? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications as Dan reported? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications unless cost wins? our SMTP relay, Postmark, or SES?", + "Dan said what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay / Postmark, SES, or Mailgun?", + "what sends transactional notifications? our SMTP relay is cheap, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay because cheap, Postmark, or SES?", +])("strong first-pass answers cannot turn unsafe D03 source spans into options: %s", text => { + let current = message("d03-qualified-list", text, "Liv"); + let quotes = extractQuotes(text); + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); +test.each([ + { + name: "inline cost qualifier", + text: + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + candidates: [ + { quote: "SMTP relay", start: 59, end: 69 }, + { quote: "Postmark", start: 71, end: 79 }, + { quote: "SES", start: 84, end: 87 }, + ], + }, + { + name: "fallback rationale before the question", + text: + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + candidates: [ + { quote: "SMTP relay", start: 74, end: 84 }, + { quote: "Postmark", start: 86, end: 94 }, + { quote: "SES", start: 99, end: 102 }, + ], + }, +])("hand-supplied D03 spans from $name are reviewed atomically", ({ text, candidates }) => { + let current = message("d03-exact-span-bypass", text, "Liv"); + expect(directAlternativeQuotes(text)).toEqual([]); + for (let candidate of candidates) { + expect(text.slice(candidate.start, candidate.end)).toBe(candidate.quote); + } + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: candidates.map(candidate => ({ + ...candidate, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual([ + "review", + "review", + "review", + ]); +}); diff --git a/apps/server/src/conversation-plan/policy-types.ts b/apps/server/src/conversation-plan/policy-types.ts new file mode 100644 index 00000000..de59474b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-types.ts @@ -0,0 +1,23 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import type { LinkedCardOptions } from "./questions"; + +export type Event = ConversationPlan.Event; +export type State = ConversationPlan.State; +export type CandidateJudgment = QuoteCandidate & { answers: Record }; +export type PolicyInput = { + channelId: string; + message: Chat.Entry; + state: State; + linkedCards?: LinkedCardOptions; + first: Record; + candidates: CandidateJudgment[]; +}; +export type PolicyResult = { + events: Event[]; + selectedTarget?: string; + candidates: Array<{ id: string; kind: "resolution" | "reopening"; targetId?: string }>; + outcomes: ConversationPlan.CandidateOutcome[]; + policyGate: string; +}; diff --git a/apps/server/src/conversation-plan/policy.ts b/apps/server/src/conversation-plan/policy.ts new file mode 100644 index 00000000..ddffcb08 --- /dev/null +++ b/apps/server/src/conversation-plan/policy.ts @@ -0,0 +1,80 @@ +import { applyCandidateEvent } from "./policy-candidate-application"; +import { beginCandidate } from "./policy-candidate-entry"; +import { runDirectNewChoice } from "./policy-candidate-new-choice"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { runScopedAssent, runScopedProposal } from "./policy-candidate-scoped"; +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { createPolicyContext } from "./policy-context"; +import { finishPolicy } from "./policy-final"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { proposeCandidate } from "./policy-proposal"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { explicitListQuotes } from "./quotes"; +import type { PolicyInput, PolicyResult } from "./policy-types"; + +export { optionIdFor } from "./policy-identity"; +export type { PolicyInput, PolicyResult } from "./policy-types"; + +export function planEvents(input: PolicyInput): PolicyResult { + let context = createPolicyContext(input); + let terminal = runInitialTerminals(context); + if (terminal) return terminal; + terminal = runRemainingTerminals(context); + if (terminal) return terminal; + prepareCandidateRun(context); + let { events } = context; + for (let [index, candidate] of input.candidates.entries()) { + let entry = beginCandidate(context, index, candidate); + if (!entry) continue; + let frame = captureCandidateRole(context, entry); + if (!frame) continue; + let spike = runCandidateVerification(context, entry, frame); + if (!spike) continue; + if (!runScopedAssent(context, entry, frame)) continue; + if (!runScopedProposal(context, entry, frame, spike)) continue; + if (!runDirectNewChoice(context, entry, frame)) continue; + let proposal = proposeCandidate(context, entry, frame); + if (!proposal) continue; + let { proposed } = proposal; + if (!proposed) continue; + applyCandidateEvent(context, entry, frame, proposed); + if (events.length >= 12) break; + } + let result = finishPolicy(context); + let listed = explicitListQuotes(input.message.text); + let bareList = listed.length >= 3 + && input.message.text.indexOf("?") < 0 + && listed.length === input.candidates.length + && listed.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + ); + if ( + bareList && !( + result.events.length === listed.length + && result.outcomes.length === listed.length + && result.outcomes.every(outcome => outcome.status === "accepted") + && result.events.every((event, index) => + event.type === "option.added" + && event.source?.start === listed[index]!.start + && event.source.end === listed[index]!.end + ) + ) + ) { + return { + events: [], + candidates: result.candidates, + outcomes: listed.map(quote => ({ + start: quote.start, + end: quote.end, + status: "review", + gate: "alternative list could not be applied together", + eventIds: [], + })), + policyGate: "alternative list could not be applied together", + }; + } + return result; +} diff --git a/apps/server/src/conversation-plan/preference.ts b/apps/server/src/conversation-plan/preference.ts new file mode 100644 index 00000000..76b966b3 --- /dev/null +++ b/apps/server/src/conversation-plan/preference.ts @@ -0,0 +1,94 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +type Event = ConversationPlan.Event; +type Thread = ConversationPlan.Thread; + +/** A later suggestion cannot override an unmet verification gate. */ +export function activeSettleDeferral( + thread: Thread, + events: readonly Event[], +): Extract | undefined { + if (thread.status === "decided" || thread.status === "discarded") return; + let deferred = events.findLast((event): event is Extract => + event.type === "settle.deferred" && event.threadId === thread.id + ); + if (!deferred) return; + return events.some(event => + event.type === "settle.resumed" && event.threadId === thread.id + && event.proposalId === deferred.proposalId && event.deferredEventId === deferred.id + ) + ? undefined + : deferred; +} + +/** A historical proposal remains on the thread for replay, even after its source withdraws. */ +export function effectivePending( + thread: Thread, + events: readonly Event[], +): Thread["pendingSettle"] { + let pending = thread.pendingSettle; + if (!pending || thread.status === "decided" || thread.status === "discarded") return; + if (activeSettleDeferral(thread, events)) return; + let proposedAt = events.findLastIndex(event => + event.threadId === thread.id && event.type === "settle.suggested" + && event.optionId === pending.optionId + && event.source.messageId === pending.messageId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + ); + if (proposedAt < 0) return pending; + let resumedAt = events.findLastIndex(event => + event.type === "settle.resumed" && event.threadId === thread.id + ); + let withdrawn = events.slice(proposedAt + 1).some(event => + event.threadId === thread.id && event.type === "stance.changed" + && event.position !== "support" && event.optionId === pending.optionId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + && events.indexOf(event) > resumedAt + ); + return withdrawn ? undefined : pending; +} + +/** Keep only independently valid sources for the latest pending settle proposal. */ +export function effectivePreference( + thread: Thread, + events: readonly Event[], +): { optionId: string; messageIds: string[] } | undefined { + let pending = thread.pendingSettle; + if (!pending || thread.status === "decided" || thread.status === "discarded") return; + if (activeSettleDeferral(thread, events)) return; + let proposedAt = events.findLastIndex(event => + event.threadId === thread.id && event.type === "settle.suggested" + && event.optionId === pending.optionId + && event.source.messageId === pending.messageId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + ); + if (proposedAt < 0) return { optionId: pending.optionId, messageIds: [pending.messageId] }; + let resumedAt = events.findLastIndex(event => + event.type === "settle.resumed" && event.threadId === thread.id + ); + let sources = events.slice(proposedAt).filter(event => + event.threadId === thread.id + && (event.type === "settle.suggested" && event === events[proposedAt] + || event.type === "settle.agreed" && event.optionId === pending.optionId) + ); + let messageIds = sources.flatMap(source => { + if (source.type !== "settle.suggested" && source.type !== "settle.agreed") return []; + if (source.source.author.kind !== "member") return []; + let actor = source.source.author.handle; + let sourceAt = events.indexOf(source); + let withdrawn = events.slice(sourceAt + 1).some(event => + event.threadId === thread.id && event.type === "stance.changed" + && event.position !== "support" && event.optionId === pending.optionId + && event.source.author.kind === "member" && event.source.author.handle === actor + && events.indexOf(event) > resumedAt + ); + return withdrawn ? [] : [source.source.messageId]; + }); + messageIds = [...new Set(messageIds)]; + return messageIds.length + ? { optionId: pending.optionId, messageIds: messageIds.slice(-8) } + : undefined; +} diff --git a/apps/server/src/conversation-plan/processor-card-context.ts b/apps/server/src/conversation-plan/processor-card-context.ts new file mode 100644 index 00000000..58807f66 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-card-context.ts @@ -0,0 +1,33 @@ +import type { LinkedCardOptions } from "./questions"; +import type { Dependencies, State } from "./processor-types"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function cardCycle(plan: Dependencies["plan"], thread: State["threads"][number]) { + let id = thread.questionnaireId; + let record = id ? plan.records.get(id) : undefined; + return { id, status: record?.status, generation: record?.history.length }; +} + +export function linkedCardOptions(plan: Dependencies["plan"], state: State): LinkedCardOptions { + let cards = new Map }>(); + for (let thread of state.threads) { + let cardId = thread.questionnaireId; + let record = cardId && plan.records.get(cardId); + if ( + !cardId || !record || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + ) continue; + let options = record.definition.questions[0]?.options; + if ( + !options || options.length > 10 + || new Set(options.map(item => item.id)).size !== options.length + ) { + continue; + } + cards.set(thread.id, { + cardId, + options: options.map(item => ({ id: item.id, label: item.label })), + }); + } + return cards; +} diff --git a/apps/server/src/conversation-plan/processor-commands.ts b/apps/server/src/conversation-plan/processor-commands.ts new file mode 100644 index 00000000..ed58394f --- /dev/null +++ b/apps/server/src/conversation-plan/processor-commands.ts @@ -0,0 +1,185 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member, State } from "./processor-types"; +import { applyCorrection, enqueue, retryMessage } from "./domain"; +import { effectsFor } from "./effects"; +import { assertOptionCapacity } from "./option-capacity"; +import { applyEvent } from "./events"; +import { assertStateShape, MAX_ANALYSIS, MAX_QUEUE } from "./validation"; +import { QUESTION_SET_VERSION } from "./question-shared"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createCommands( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, + wake: () => void, +) { + async function accept(message: Chat.Entry): Promise { + await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let index = plan.chat.entries.length; + plan.chat.entries.push(message); + try { + if (previous.queue.length < MAX_QUEUE) { + plan.conversationPlan = enqueue(previous, message.id); + } else { + let skipped: ConversationPlan.AnalysisRecord = { + messageId: message.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "unavailable", + status: "unlinked", + passes: [], + policyGate: "queue full; skipped", + eventIds: [], + }; + let next = { + ...previous, + revision: previous.revision + 1, + analysis: [...previous.analysis, skipped].slice(-MAX_ANALYSIS), + }; + assertStateShape(next); + plan.conversationPlan = next; + } + await deps.persist(); + } catch (error) { + plan.conversationPlan = previous; + plan.chat.entries.splice(index, 1); + throw error; + } + }); + } + + function afterMessage(): void { + publish(); + wake(); + } + + async function correct(action: ConversationPlan.CorrectionAction, actor: Member) { + let changed = false; + let state = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let pending = plan.conversationPlanPendingEffects; + let next = applyCorrection(previous, action, actor, Date.now(), plan.chat.entries); + if (next === previous) return previous; + if (action.change.kind === "add-excerpt") { + let thread = next.threads.find(item => item.id === action.threadId); + let record = thread?.questionnaireId && plan.records.get(thread.questionnaireId); + if (!record || !["open", "reopened"].includes(record.status)) { + throw new Error("The decision card is no longer open"); + } + } + plan.conversationPlan = next; + try { + let accepted = next.events.at(-1)!; + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor([accepted], next, undefined, plan.records), + ); + assertOptionCapacity(plan); + await deps.persist(); + changed = true; + return next; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = pending; + throw error; + } + }); + if (changed) { + publish(); + wake(); + } + return { eventId: `human:${actor.handle}:${action.actionId}`, revision: state.revision }; + } + + async function record( + make: ConversationPlan.Event | ((state: State) => ConversationPlan.Event | undefined), + pendingCardActionId?: string, + ) { + let changed = false; + let consumed = false; + await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let pending = plan.conversationPlanPendingEffects; + let actions = plan.pendingCardActions; + if (pendingCardActionId && actions[0]?.id !== pendingCardActionId) { + if (previous.events.some(event => event.id === pendingCardActionId)) return; + throw new Error("card action is not at the FIFO head"); + } + let event = typeof make === "function" ? make(previous) : make; + if (pendingCardActionId && event && event.id !== pendingCardActionId) { + throw new Error("card action ID does not match mirror event"); + } + let next = event ? applyEvent(previous, event) : previous; + if (next === previous && !pendingCardActionId) return; + plan.conversationPlan = next; + if (pendingCardActionId) plan.pendingCardActions = actions.slice(1); + try { + if (next !== previous && event) { + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor([event], next, undefined, plan.records), + ); + } + await deps.persist(); + changed = next !== previous; + consumed = !!pendingCardActionId; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = pending; + plan.pendingCardActions = actions; + throw error; + } + }); + if (changed) publish(); + if (changed || consumed) wake(); + return changed; + } + + async function retry(actionId: string, messageId: string, actor: Member) { + let changed = false; + let queued = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + actor.kind !== "member" || !actor.handle + || typeof actionId !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(actionId) + || typeof messageId !== "string" || !messageId || messageId.length > 200 + ) throw new Error("invalid analysis retry"); + let id = `human:${actor.handle}:${actionId}`; + let existing = plan.conversationPlanRetries.find(item => item.id === id); + if (existing) { + if (existing.messageId !== messageId) throw new Error("analysis retry ID collision"); + return plan.conversationPlan.queue.some(item => + item.messageId === messageId && item.status === "pending" + ); + } + if (plan.conversationPlanRetries.length >= 4096) { + throw new Error("analysis retry history is full"); + } + let previous = plan.conversationPlan; + let retries = plan.conversationPlanRetries; + plan.conversationPlan = retryMessage(previous, messageId); + plan.conversationPlanRetries = [...retries, { id, messageId }]; + try { + await deps.persist(); + changed = true; + return true; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanRetries = retries; + throw error; + } + }); + if (changed) { + publish(); + wake(); + } + return { messageId, queued }; + } + return { accept, afterMessage, correct, record, retry }; +} diff --git a/apps/server/src/conversation-plan/processor-fields.ts b/apps/server/src/conversation-plan/processor-fields.ts new file mode 100644 index 00000000..3442eee1 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-fields.ts @@ -0,0 +1,48 @@ +import { isDeepStrictEqual } from "node:util"; +import { type Effect, MAX_EFFECTS } from "./effects"; +import { QUESTION_SET_VERSION } from "./question-shared"; +import type { Analysis, Dependencies, State } from "./processor-types"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export const MAX_PENDING_EFFECTS = MAX_EFFECTS; + +export function appendEffects(plan: Dependencies["plan"], effects: readonly Effect[]): Effect[] { + let pending = [...plan.conversationPlanPendingEffects]; + for (let effect of effects) { + if (plan.conversationPlanEffects.includes(effect.key)) continue; + let existing = pending.find(item => item.key === effect.key); + if (existing) { + if ( + effect.key === "job:heading:document" + && effect.kind === "job" && existing.kind === "job" + && effect.intent.kind === "heading" && existing.intent.kind === "heading" + ) continue; + if (!isDeepStrictEqual(existing, effect)) { + throw new Error("conversation effect key collision"); + } + continue; + } + if (pending.length >= MAX_PENDING_EFFECTS) { + throw new Error("conversation effect outbox is full"); + } + pending.push(effect); + } + return pending; +} + +export function failure(reason: string): Analysis { + return { + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "unavailable", + status: "failed", + passes: [], + error: reason, + }; +} + +export function sameThreads(before: State, now: State): boolean { + return before.threads.length === now.threads.length + && before.threads.every((thread, index) => + thread.id === now.threads[index]?.id && thread.version === now.threads[index]?.version + ); +} diff --git a/apps/server/src/conversation-plan/processor-outbox.ts b/apps/server/src/conversation-plan/processor-outbox.ts new file mode 100644 index 00000000..20091de9 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-outbox.ts @@ -0,0 +1,99 @@ +import { effectsFor, MAX_EFFECTS, recoverable, runEffects } from "./effects"; +import type { Dependencies, EffectCommands } from "./processor-types"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createOutbox( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + wake: () => void, + effectCommands: EffectCommands | undefined, +) { + let recovered = false; + async function markApplied(key: string): Promise { + await deps.exclusive(async () => { + if (!plan.conversationPlanPendingEffects.some(item => item.key === key)) return; + let pending = plan.conversationPlanPendingEffects; + let receipts = plan.conversationPlanEffects; + plan.conversationPlanPendingEffects = pending.filter(item => item.key !== key); + plan.conversationPlanEffects = [...receipts, key].slice(-MAX_EFFECTS); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanPendingEffects = pending; + plan.conversationPlanEffects = receipts; + throw error; + } + }); + } + + async function recover(): Promise { + if (recovered) return; + await deps.exclusive(async () => { + if (!active()) return; + let heldThreads = new Set( + plan.pendingCardActions.filter(action => + ["decided", "reopened", "discarded"].includes(action.kind) + ).map(action => action.threadId), + ); + let historical = plan.conversationPlan.events.filter(event => + !heldThreads.has(event.threadId) + && (event.type === "settle.suggested" || event.type === "settle.agreed" + || event.type === "settle.deferred" || event.type === "settle.resumed" + || event.type === "stance.changed") + ); + let advisory = effectsFor(historical, plan.conversationPlan, undefined, plan.records) + .filter(effect => + effect.kind === "suggest" || effect.kind === "prompt" + || effect.kind === "defer-prompt" + ); + let missing = [ + ...effectsFor( + recoverable(plan.conversationPlan), + plan.conversationPlan, + undefined, + plan.records, + ), + ...advisory, + ] + .filter(effect => + !plan.conversationPlanPendingEffects.some(item => item.key === effect.key) + && !plan.conversationPlanEffects.includes(effect.key) + ); + if (missing.length === 0) return; + let previous = plan.conversationPlanPendingEffects; + plan.conversationPlanPendingEffects = appendEffects(plan, missing); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanPendingEffects = previous; + throw error; + } + }); + recovered = true; + } + + async function drainEffects(): Promise { + while (active()) { + let commands = effectCommands; + if (!commands) return; + let snapshot = await deps.exclusive(async () => + structuredClone(plan.conversationPlanPendingEffects) + ); + if (snapshot.length === 0) return; + let completed = await runEffects({ + ...commands, + applied: key => plan.conversationPlanEffects.includes(key), + markApplied, + }, snapshot); + if (completed === 0) return; + } + } + + function setEffects(commands: EffectCommands): void { + effectCommands = commands; + wake(); + } + return { markApplied, recover, drainEffects, setEffects }; +} diff --git a/apps/server/src/conversation-plan/processor-research-admission.ts b/apps/server/src/conversation-plan/processor-research-admission.ts new file mode 100644 index 00000000..6063fd47 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-admission.ts @@ -0,0 +1,125 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { createHash } from "node:crypto"; +import { offerResearch } from "./domain"; +import { renderResearchTask } from "./validation"; +import type { Interpretation } from "./interpret"; +import type { State } from "./processor-types"; +import { researchQuoteFocus, researchQuoteSupportsPair } from "./processor-research-quotes"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +function researchId(kind: string, parts: readonly string[]): string { + let hash = createHash("sha256").update(JSON.stringify(parts)).digest("hex"); + return `research:${kind}:${hash}`; +} + +export function admitResearchOffer( + before: State, + next: State, + message: Chat.Entry, + candidate: Interpretation["researchOffer"], + card: { cardId: string; options: ReadonlyArray<{ id: string; label: string }> } | undefined, + cardRecordExists: boolean, +): State { + if (!candidate || message.author.kind !== "member") return next; + let prior = before.threads.find(item => item.id === candidate.threadId); + let thread = next.threads.find(item => item.id === candidate.threadId); + if (!prior || !thread || !["exploring", "leaning", "reopened"].includes(thread.status)) { + return next; + } + let isConcern = candidate.kind === "current-cost-concern"; + let kind = isConcern ? "current-cost-concern" : "current-cost-comparison"; + let currentOptions = thread.contributions.filter(item => item.kind === "option"); + let priorOptions = prior.contributions.filter(item => item.kind === "option"); + if ( + isConcern + ? ![3, 4].includes(currentOptions.length) + || candidate.optionIds.length !== currentOptions.length + || candidate.optionIds.length !== priorOptions.length + || !candidate.optionIds.every((id, index) => id === currentOptions[index]?.id) + || !candidate.optionIds.every((id, index) => id === priorOptions[index]?.id) + || next.threads.filter(item => + ["exploring", "leaning", "reopened"].includes(item.status) + && [2, 3, 4].includes( + item.contributions.filter(value => value.kind === "option").length, + ) + ).length !== 1 + : candidate.optionIds.length !== 2 + || candidate.optionIds[0] === candidate.optionIds[1] + || currentOptions.length !== 2 + ) return next; + let options = candidate.optionIds.map(id => { + let previous = prior.contributions.find(item => item.id === id && item.kind === "option"); + let current = thread.contributions.find(item => item.id === id && item.kind === "option"); + return previous && current + && (previous.displayLabel ?? previous.text) === (current.displayLabel ?? current.text) + ? current + : undefined; + }); + if (options.some(item => !item)) return next; + let selectedOptions = options as ConversationPlan.Contribution[]; + if (isConcern) { + if (cardRecordExists && !card) return next; + if ( + card && (card.cardId !== thread.questionnaireId + || card.options.length !== selectedOptions.length + || selectedOptions.some(option => + !card.options.some(item => + item.id === option.id && item.label === (option.displayLabel ?? option.text) + ) + )) + ) return next; + let focus = researchQuoteFocus(candidate.source.quote, selectedOptions); + if ( + !focus || (candidate.kind === "current-cost-concern" + && candidate.focusOptionId !== focus.focusOptionId) + ) return next; + } else if (!researchQuoteSupportsPair(candidate.source.quote, selectedOptions)) return next; + let needId = researchId("need", [candidate.threadId, kind]); + let contextId = researchId("context", [...candidate.optionIds].sort()); + if ( + (next.researchOffers ?? []).some(item => + item.source.messageId === message.id + || item.needId === needId && ( + item.status !== "dismissed" || item.contextId === contextId + ) + ) + ) return next; + let frozen = selectedOptions.map(item => ({ + id: item.id, + labelAtOffer: item.displayLabel ?? item.text, + })); + let task: ConversationPlan.ResearchTask = isConcern + ? { + kind: "current-cost-concern", + threadId: thread.id, + observedEventCount: next.events.length, + observedThreadVersion: thread.version, + options: frozen as Extract< + ConversationPlan.ResearchTask, + { kind: "current-cost-concern" } + >["options"], + ...(candidate.kind === "current-cost-concern" && candidate.focusOptionId + ? { focusOptionId: candidate.focusOptionId } + : {}), + } + : { + kind: "current-cost-comparison", + threadId: thread.id, + observedEventCount: next.events.length, + observedThreadVersion: thread.version, + options: frozen as Extract< + ConversationPlan.ResearchTask, + { kind: "current-cost-comparison" } + >["options"], + }; + let source = candidate.source; + return offerResearch(next, { + id: researchId("offer", [message.id]), + needId, + contextId, + source, + threadId: thread.id, + task, + brief: renderResearchTask(task, source), + }, message); +} diff --git a/apps/server/src/conversation-plan/processor-research-consent.ts b/apps/server/src/conversation-plan/processor-research-consent.ts new file mode 100644 index 00000000..f2d64ff8 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-consent.ts @@ -0,0 +1,90 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member, ResearchCommand, ResearchStart } from "./processor-types"; +import { actOnResearchOffer } from "./domain"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createResearchConsent( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, + markApplied: (key: string) => Promise, + report: (error: unknown) => void, +) { + async function researchConsent( + command: ResearchCommand, + actor: Member, + principalId: string, + start?: ResearchStart, + ): Promise> { + let changed = false; + let { offer, state, pending, applied } = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + !command || typeof command.offerId !== "string" || !command.offerId + || command.offerId.length > 200 || !actor || actor.kind !== "member" + || !actor.handle || typeof principalId !== "string" || !principalId + || principalId.length > 200 + ) throw new Error("invalid research consent action"); + let previous = plan.conversationPlan; + let previousPending = plan.conversationPlanPendingEffects; + let next = previous; + if (command.choice === "resume") { + if (command.actionId !== undefined) throw new Error("invalid research resume action"); + } else if (command.choice === "research" || command.choice === "dismiss") { + next = actOnResearchOffer(previous, command.offerId, { + id: command.actionId, + kind: command.choice, + actor, + principalId, + at: Date.now(), + }); + } else throw new Error("invalid research consent choice"); + let offer = next.researchOffers?.find(item => item.id === command.offerId); + if (!offer || command.choice === "resume" && offer.status !== "accepted") { + throw new Error("research offer is not accepted"); + } + let key = `research:${offer.id}`; + if (next !== previous) { + plan.conversationPlan = next; + try { + if (offer.status === "accepted") { + plan.conversationPlanPendingEffects = appendEffects(plan, [{ + key, + kind: "research", + offerId: offer.id, + }]); + } + await deps.persist(); + changed = true; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = previousPending; + throw error; + } + } + let pending = plan.conversationPlanPendingEffects.some(item => item.key === key); + let applied = plan.conversationPlanEffects.includes(key); + if (offer.status === "accepted" && !pending && !applied) { + throw new Error("accepted research offer has no delivery record"); + } + return { offer: structuredClone(offer), state: next, pending, applied }; + }); + if (changed) publish(); + let base = { offerId: offer.id, status: offer.status, revision: state.revision }; + if (offer.status === "dismissed") return { ...base, execution: "none" }; + if (applied) return { ...base, execution: "started" }; + if (!pending || !start) return { ...base, execution: "pending-retry" }; + try { + let outcome = await start(offer); + if (outcome.execution === "pending-owner") return { ...base, execution: "pending-owner" }; + await markApplied(`research:${offer.id}`); + return { ...base, ...outcome }; + } catch (error) { + report(error); + return { ...base, execution: "pending-retry" }; + } + } + return researchConsent; +} diff --git a/apps/server/src/conversation-plan/processor-research-quotes.ts b/apps/server/src/conversation-plan/processor-research-quotes.ts new file mode 100644 index 00000000..22a15392 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-quotes.ts @@ -0,0 +1,96 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { researchNamedOptionIds } from "./validation"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +function mentionsOptionLabel(quote: string, label: string): boolean { + let names = [label, label.trim().split(/\s+/).at(-1) ?? ""]; + return names.some(name => { + if (name.length < 2 || /^(?:app|service|server|storage|provider|option)$/i.test(name)) { + return false; + } + let escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}(?=$|[^\\p{L}\\p{N}])`, "iu") + .test(quote); + }); +} + +function exactOptionName(name: string, label: string): boolean { + let normalized = name.trim().toLocaleLowerCase().replace(/\s+/g, " "); + return [label, label.trim().split(/\s+/).at(-1) ?? ""].some(alias => + alias.length >= 2 && alias.toLocaleLowerCase() === normalized + ); +} + +function exactNamedPair( + quote: string, + options: readonly Pick[], +): boolean { + let body = quote.trim().replace(/[.!?]$/, ""); + let match = body.match( + /^compare\s+(.+?)\s+(?:and|or|vs\.?|versus)\s+(.+?)(?:\s+(?:costs?|prices?|pricing))?$/i, + ) ?? body.match( + /^how do\s+(.+?)\s+and\s+(.+?)\s+(?:costs?|prices?|pricing)\s+compare$/i, + ) ?? body.match( + /^(.+?)\s+(?:vs\.?|versus)\s+(.+?)(?:\s+(?:costs?|prices?|pricing))?$/i, + ); + if (!match) return false; + let first = options.find(option => + exactOptionName(match[1]!, option.displayLabel ?? option.text) + ); + let second = options.find(option => + exactOptionName(match[2]!, option.displayLabel ?? option.text) + ); + return !!first && !!second && first !== second; +} + +export function researchQuoteSupportsPair( + quote: string, + options: readonly Pick[], +): boolean { + if (options.length !== 2) return false; + if ( + options.some(option => + option.displayLabel && mentionsOptionLabel(quote, option.text) + && !mentionsOptionLabel(quote, option.displayLabel) + ) + ) return false; + let named = + options.filter(option => mentionsOptionLabel(quote, option.displayLabel ?? option.text)).length; + if (named === 2) return exactNamedPair(quote, options); + if (named === 0) { + return /^(?:I|we) (?:don't|do not) know current provider (?:prices|pricing|costs)\.?$/i + .test(quote); + } + let concern = quote.match( + /^(.+?)['’]s\s+(?:egress|costs?|prices?|pricing|fees?)\s+(?:could|might|may)\s+matter(?:\s+for\s+([^.!?]+))?[.!?]?$/i, + ); + return !!concern + && options.some(option => mentionsOptionLabel(concern[1]!, option.displayLabel ?? option.text)) + && (!concern[2] || /^[a-z][a-z\s-]{0,80}$/.test(concern[2])); +} + +export function researchQuoteFocus( + quote: string, + options: readonly ConversationPlan.Contribution[], +): { focusOptionId?: string } | undefined { + if (![3, 4].includes(options.length)) return; + let current = options.map(item => ({ + id: item.id, + labelAtOffer: item.displayLabel ?? item.text, + })); + if ( + /^(?:I|we) (?:don't|do not) know current provider (?:prices|pricing|costs)\.?$/i.test(quote) + || /^We still need current provider (?:prices|pricing|costs)\.?$/i.test(quote) + ) { + return {}; + } + let concern = quote.match( + /^(.+?)['’]s\s+(?:egress|costs?|prices?|pricing|fees?)\s+(?:could|might|may)\s+matter(?:\s+for\s+([^.!?]+))?[.!?]?$/i, + ); + if (!concern || concern[2] && !/^[a-z][a-z\s-]{0,80}$/.test(concern[2])) return; + let named = researchNamedOptionIds(quote, current); + if (named.length !== 1) return; + let operand = researchNamedOptionIds(concern[1]!, current); + if (operand.length !== 1 || operand[0] !== named[0]) return; + return { focusOptionId: named[0] }; +} diff --git a/apps/server/src/conversation-plan/processor-run.ts b/apps/server/src/conversation-plan/processor-run.ts new file mode 100644 index 00000000..24d53498 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-run.ts @@ -0,0 +1,199 @@ +import { isDeepStrictEqual } from "node:util"; +import type { Dependencies, State } from "./processor-types"; +import { type Interpretation, interpretMessage } from "./interpret"; +import { completeAnalysis } from "./domain"; +import { assertEventCapacity, ConversationCapacityError, currentScopedProposal } from "./events"; +import { effectsFor } from "./effects"; +import { assertOptionCapacity } from "./option-capacity"; +import { cardCycle, linkedCardOptions } from "./processor-card-context"; +import { admitResearchOffer } from "./processor-research-admission"; +import { appendEffects, failure, sameThreads } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createRun( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + controller: AbortController, + publish: () => void, + recover: () => Promise, + drainEffects: () => Promise, +) { + async function run(): Promise { + await recover(); + await drainEffects(); + while (active()) { + let stale = 0; + while (active()) { + let input = await deps.exclusive(async () => { + if (!active()) return undefined; + let item = plan.conversationPlan.queue.find(item => item.status === "pending"); + if (!item) return undefined; + let index = plan.chat.entries.findIndex(entry => entry.id === item.messageId); + if (index < 0) throw new Error("queued conversation message is missing"); + return { + channelId: plan.id, + message: structuredClone(plan.chat.entries[index]!), + recent: structuredClone(plan.chat.entries.slice(0, index).slice(-12)), + state: structuredClone(plan.conversationPlan), + linkedCards: linkedCardOptions(plan, plan.conversationPlan), + cardCycles: new Map(plan.conversationPlan.threads.map(thread => [ + thread.id, + cardCycle(plan, thread), + ])), + }; + }); + if (!input) return false; + let { cardCycles, ...interpretInput } = input; + let interpretation: Interpretation; + try { + interpretation = await (deps.interpret ?? interpretMessage)( + interpretInput, + controller.signal, + ); + } catch { + interpretation = { events: [], analysis: failure("Jev interpretation failed") }; + } + let outcome = await deps.exclusive(async () => { + if (!active()) return "stopped" as const; + let current = plan.conversationPlan; + if ( + !current.queue.some(item => + item.messageId === interpretInput.message.id && item.status === "pending" + ) + ) return "gone" as const; + let affected = new Set(interpretation.events.map(event => event.threadId)); + if (interpretation.researchOffer) affected.add(interpretation.researchOffer.threadId); + if ( + plan.pendingCardActions.some(action => + affected.has(action.threadId) + && ["decided", "reopened", "discarded"].includes(action.kind) + ) + ) return "waiting" as const; + if (!sameThreads(interpretInput.state, current)) return "stale" as const; + let currentCards = linkedCardOptions(plan, current); + if ( + [...affected].some(id => + !isDeepStrictEqual(interpretInput.linkedCards.get(id), currentCards.get(id)) + ) + ) return "stale" as const; + if ( + [...affected].some(id => { + let thread = current.threads.find(item => item.id === id); + return thread && !isDeepStrictEqual(cardCycles.get(id), cardCycle(plan, thread)); + }) + ) return "stale" as const; + let admittedEvents = interpretation.events.filter(event => { + if ( + event.type !== "scoped-choice.proposed" + && event.type !== "scoped-choice.agreed" + ) return true; + let thread = current.threads.find(item => item.id === event.threadId); + let card = currentCards.get(event.threadId); + return thread?.questionnaireId === event.cardId && card?.cardId === event.cardId + && card.options.filter(item => + item.id === event.optionId && item.label === event.label + ).length === 1 + && (event.type !== "scoped-choice.agreed" || !!thread + && currentScopedProposal(thread, current.events)?.id === event.proposalId); + }); + let next: State; + try { + next = completeAnalysis( + current, + interpretInput.message.id, + admittedEvents, + interpretInput.message, + interpretation.analysis, + ); + assertEventCapacity(next, plan.pendingCardActions.length); + assertOptionCapacity({ ...plan, conversationPlan: next }); + } catch (error) { + next = completeAnalysis( + current, + interpretInput.message.id, + [], + interpretInput.message, + failure( + error instanceof ConversationCapacityError + ? error.message + : "analysis failed validation", + ), + ); + } + try { + let researchThread = next.threads.find(item => + item.id === interpretation.researchOffer?.threadId + ); + let cardId = researchThread?.questionnaireId; + next = admitResearchOffer( + current, + next, + interpretInput.message, + interpretation.researchOffer, + currentCards.get(researchThread?.id ?? ""), + !!cardId && plan.records.has(cardId), + ); + } catch { + // A rejected optional offer must not discard valid planning events. + } + let pending = plan.conversationPlanPendingEffects; + plan.conversationPlan = next; + try { + let record = next.analysis.find(item => item.messageId === interpretInput.message.id); + let accepted = next.events.filter(event => record?.eventIds.includes(event.id)); + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor(accepted, next, record, plan.records), + ); + await deps.persist(); + return "committed" as const; + } catch (error) { + plan.conversationPlan = current; + plan.conversationPlanPendingEffects = pending; + throw error; + } + }); + if (outcome === "stopped") return false; + if (outcome === "waiting") return true; + if (outcome === "stale") { + if (++stale < 3) continue; + let failed = await deps.exclusive(async () => { + if (!active()) return false; + let current = plan.conversationPlan; + let message = plan.chat.entries.find(entry => entry.id === interpretInput.message.id); + if ( + !message + || !current.queue.some(item => + item.messageId === interpretInput.message.id && item.status === "pending" + ) + ) return false; + plan.conversationPlan = completeAnalysis( + current, + message.id, + [], + message, + failure("analysis changed during inference; retry"), + ); + try { + await deps.persist(); + return true; + } catch (error) { + plan.conversationPlan = current; + throw error; + } + }); + if (failed) publish(); + break; + } + if (outcome === "committed") { + publish(); + await drainEffects(); + } + break; + } + } + return false; + } + return run; +} diff --git a/apps/server/src/conversation-plan/processor-scoped-save.ts b/apps/server/src/conversation-plan/processor-scoped-save.ts new file mode 100644 index 00000000..c56bd987 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-scoped-save.ts @@ -0,0 +1,115 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member } from "./processor-types"; +import { activeScopedSupport, applyEvent, currentScopedProposal } from "./events"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createScopedSave( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, +) { + async function saveScopedChoice(input: ConversationPlan.ScopedChoiceSave, actor: Member) { + let changed = false; + let result = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + !input || typeof input !== "object" || Array.isArray(input) + || Object.keys(input).some(key => + ![ + "actionId", + "threadId", + "expectedVersion", + "proposalId", + "cardId", + "optionId", + "expectedLabel", + "expectedGeneration", + ].includes(key) + ) + || actor?.kind !== "member" || !actor.handle || actor.handle.length > 200 + || typeof input.actionId !== "string" + || !/^[A-Za-z0-9._:-]{1,128}$/.test(input.actionId) + || [input.threadId, input.proposalId, input.cardId, input.optionId].some(value => + typeof value !== "string" || !value || value.length > 200 + ) + || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0 + || !Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 0 + || input.expectedLabel !== undefined && ( + typeof input.expectedLabel !== "string" || !input.expectedLabel.trim() + || input.expectedLabel.length > 200 + ) + ) throw new Error("invalid scoped choice save action"); + let previous = plan.conversationPlan; + let eventId = `human:${actor.handle}:${input.actionId}`; + let existing = previous.events.find(event => event.id === eventId); + if (existing) { + if ( + existing.type !== "scoped-choice.saved" + || existing.actor.kind !== "member" || existing.actor.handle !== actor.handle + || existing.threadId !== input.threadId + || existing.observedThreadVersion !== input.expectedVersion + || existing.proposalId !== input.proposalId + || existing.cardId !== input.cardId || existing.optionId !== input.optionId + || existing.expectedLabel !== input.expectedLabel + || existing.expectedGeneration !== input.expectedGeneration + ) throw new Error("scoped choice action ID already used"); + return { eventId, revision: previous.revision }; + } + let thread = previous.threads.find(item => item.id === input.threadId); + let record = plan.records.get(input.cardId); + let proposal = thread && currentScopedProposal(thread, previous.events); + let options = record?.definition.questions[0]?.options ?? []; + let option = options.filter(item => item.id === input.optionId); + if ( + !thread || thread.version !== input.expectedVersion + || thread.status === "decided" || thread.status === "discarded" + || thread.questionnaireId !== input.cardId || !proposal + || proposal.id !== input.proposalId || proposal.optionId !== input.optionId + || !record || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + || record.history.length !== input.expectedGeneration + || option.length !== 1 || option[0].label !== proposal.label + || input.expectedLabel !== undefined && input.expectedLabel !== proposal.label + ) throw new Error("scoped choice card or proposal is stale"); + let support = activeScopedSupport(previous.events, proposal); + if (support.length === 0) throw new Error("scoped choice has no active support"); + let legacy = support[0]?.id === proposal.id && support.length <= 2; + let agreement = legacy && support[1]?.type === "scoped-choice.agreed" + ? support[1] + : undefined; + let saved: ConversationPlan.Event = { + id: eventId, + type: "scoped-choice.saved", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "human", + actor, + at: Date.now(), + proposalId: proposal.id, + ...(legacy ? {} : { supportEventIds: support.map(item => item.id) }), + ...(agreement ? { agreementId: agreement.id } : {}), + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + sources: support.map(item => item.source), + expectedGeneration: input.expectedGeneration, + ...(input.expectedLabel === undefined ? {} : { expectedLabel: input.expectedLabel }), + }; + let next = applyEvent(previous, saved); + plan.conversationPlan = next; + try { + await deps.persist(); + } catch (error) { + plan.conversationPlan = previous; + throw error; + } + changed = true; + return { eventId, revision: next.revision }; + }); + if (changed) publish(); + return result; + } + return saveScopedChoice; +} diff --git a/apps/server/src/conversation-plan/processor-types.ts b/apps/server/src/conversation-plan/processor-types.ts new file mode 100644 index 00000000..4bcc3294 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-types.ts @@ -0,0 +1,43 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { EffectDeps } from "./effects"; +import type { Interpretation, InterpretInput } from "./interpret"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export type State = ConversationPlan.State; + +export type Member = Extract; + +export type Analysis = Omit; + +export type Dependencies = { + plan: Pick< + Plan, + | "id" + | "chat" + | "conversationPlan" + | "conversationPlanRetries" + | "conversationPlanEffects" + | "conversationPlanPendingEffects" + | "pendingCardActions" + | "records" + >; + exclusive: (action: () => Promise) => Promise; + persist: () => Promise; + publish: (state: State) => void; + active: () => boolean; + interpret?: (input: InterpretInput, signal: AbortSignal) => Promise; + effects?: EffectCommands; + onError?: (error: unknown) => void; +}; + +export type EffectCommands = Omit; + +export type ResearchCommand = + | { offerId: string; choice: "research" | "dismiss"; actionId: string } + | { offerId: string; choice: "resume"; actionId?: never }; + +export type ResearchStart = (offer: ConversationPlan.ResearchOffer) => Promise< + | { execution: "started"; researchRequestId: string } + | { execution: "pending-owner" } +>; diff --git a/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts b/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts new file mode 100644 index 00000000..9b2bbf0d --- /dev/null +++ b/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts @@ -0,0 +1,244 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { HarnessV1StreamPart } from "@ai-sdk/harness"; + +type Call = Extract; +const HEADING = "[Background job: heading]\nDraft the title."; +const REFINE = '[Background job: refine]\nDecision card card-a: "Which approach?"'; +const SCRIPT = [{ tool: "refine_decision", args: { revision: "$revision", id: "$target" } }]; + +async function contract( + script: unknown = SCRIPT, + observe: Parameters[1] = {}, +) { + let dir = await mkdtemp(join(tmpdir(), "prompt-scripted-contract-")); + for (let kind of ["heading", "refine", "suggest", "prose"]) { + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + } + let driver = createPromptScriptedHarness(dir, observe); + let session = await driver.fake.doStart({ sessionId: "prompt-contract" } as never); + let parts: HarnessV1StreamPart[] = []; + let queue: Call[] = []; + let waiting: Array<(call: Call) => void> = []; + return { + dir, + driver, + session, + parts, + run(prompt: unknown, signal?: AbortSignal) { + return session.doPromptTurn({ + prompt, + tools: [], + abortSignal: signal, + emit(part: HarnessV1StreamPart) { + parts.push(part); + if (part.type !== "tool-call") return; + let listener = waiting.shift(); + if (listener) listener(part); + else queue.push(part); + }, + } as never); + }, + next() { + let call = queue.shift(); + if (call) return Promise.resolve(call); + let next = Promise.withResolvers(); + waiting.push(next.resolve); + return next.promise; + }, + async [Symbol.asyncDispose]() { + await driver.fake.shutdown(); + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +test.each([ + "", + "ordinary message", + "\n" + HEADING, + "[Background job: unknown]", + HEADING + "\n[Background job: refine]", + HEADING + '\nDecision card card-a: "Question?"', + "[Background job: refine]", + REFINE + '\nDecision card card-b: "Other?"', + "[Background job: suggest]\nDecision card id: card-a", + "[Background job: prose]\nDecision card id: " + "x".repeat(201), + { role: "user", content: [{ type: "image", image: "invalid" }] }, +])("rejects invalid or ambiguous prompt context %# before any model tool call", async prompt => { + await using h = await contract(); + let turn = await h.run(prompt); + await turn.done; + expect(h.driver.calls).toEqual([]); + expect(h.parts.filter(part => part.type === "error")).toHaveLength(1); + expect(h.parts.filter(part => part.type === "finish-step")).toHaveLength(1); +}); + +test.each([ + [HEADING, "document"], + [REFINE, "card-a"], + ['[Background job: suggest]\nDecision card card-b: "Question?"', "card-b"], + ["[Background job: prose]\nDecision card id: card-c", "card-c"], +])( + "reads returned revision and derives only the exact prompt target %#", + async (prompt, target) => { + await using h = await contract(); + let turn = await h.run({ role: "user", content: [{ type: "text", text: prompt }] }); + let read = await h.next(); + expect(read.toolName).toBe("read_plan"); + expect(JSON.parse(read.input)).toEqual({}); + await turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":7}' }); + let call = await h.next(); + expect(JSON.parse(call.input)).toEqual({ revision: 7, id: target }); + await turn.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan", "refine_decision"]); + expect(h.driver.results).toHaveLength(2); + expect(h.driver.tools).toEqual([[]]); + expect(h.parts.filter(part => part.type === "error")).toEqual([]); + }, +); + +test.each([ + { output: "bad JSON" }, + { output: "null" }, + { output: "[]" }, + { output: "{}" }, + { output: '{"revision":-1}' }, + { output: '{"revision":1.5}' }, + { output: '{"revision":"7"}' }, + { output: '{"revision":9007199254740992}' }, + { output: '{"revision":7}', isError: true }, + { output: { revision: 7 } }, +])("refuses invalid read_plan result %# before the dependent scripted call", async result => { + await using h = await contract(); + let turn = await h.run(REFINE); + let read = await h.next(); + await turn.submitToolResult({ toolCallId: read.toolCallId, ...result }); + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); + expect(h.parts.filter(part => part.type === "error")).toHaveLength(1); +}); + +test("sequential original calls each use a new returned revision without tool prefiltering", async () => { + await using h = await contract([ + ...SCRIPT, + { tool: "edit_plan", args: { revision: "$revision", nested: ["$target", "$revision"] } }, + ]); + let turn = await h.run(REFINE); + for (let revision of [2, 9]) { + let read = await h.next(); + expect(read.toolName).toBe("read_plan"); + await turn.submitToolResult({ + toolCallId: read.toolCallId, + output: JSON.stringify({ revision }), + }); + let call = await h.next(); + expect(JSON.parse(call.input).revision).toBe(revision); + if (revision === 9) expect(JSON.parse(call.input).nested).toEqual(["card-a", 9]); + await turn.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + } + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "edit_plan", + ]); + expect(h.driver.results).toHaveLength(4); +}); + +test("an empty original script finishes without an added read or dependent call", async () => { + await using h = await contract([]); + let turn = await h.run(HEADING); + await turn.done; + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(h.parts.filter(part => part.type === "finish-step")).toHaveLength(1); +}); + +test("local stop releases a held worker without aborting the caller", async () => { + await using h = await contract(); + await writeFile(join(h.dir, "heading.hold"), "hold"); + let controller = new AbortController(); + let escape: ReturnType | undefined; + try { + let turn = await h.run(HEADING, controller.signal); + await h.driver.entered; + escape = setTimeout(() => controller.abort(), 500); + await h.session.doStop(); + await turn.done; + expect(controller.signal.aborted).toBe(false); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + } finally { + clearTimeout(escape); + controller.abort(); + } +}); + +test("shutdown and done drain the admitted result observer before idempotent destruction", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let finished = false; + await using h = await contract(SCRIPT, { + onResult: async () => { + entered.resolve(); + await release.promise; + finished = true; + }, + }); + let turn = await h.run(REFINE); + let read = await h.next(); + let submitted = turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":3}' }); + await entered.promise; + let drained = false; + let done = false; + void turn.done.then(() => { + done = true; + }); + let shutdown = h.driver.fake.shutdown().then(() => { + drained = true; + }); + try { + await Bun.sleep(10); + expect(finished).toBe(false); + expect(done).toBe(false); + expect(drained).toBe(false); + expect(h.driver.destroyed()).toBe(0); + } finally { + release.resolve(); + await submitted; + await shutdown; + } + await turn.done; + expect(finished).toBe(true); + expect(done).toBe(true); + expect(drained).toBe(true); + expect(h.driver.destroyed()).toBe(1); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); + expect(h.parts.filter(part => part.type === "tool-result")).toEqual([]); + await h.session.doDestroy(); + await h.driver.fake.shutdown(); + expect(h.driver.destroyed()).toBe(1); + await expect(h.driver.fake.doStart({ sessionId: "late" } as never)).rejects.toThrow("shut down"); +}); + +test("an admitted observer failure surfaces on the model stream and blocks dependent calls", async () => { + await using h = await contract(SCRIPT, { + onResult: () => { + throw new Error("observer failed"); + }, + }); + let turn = await h.run(REFINE); + let read = await h.next(); + await turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":3}' }); + await turn.done; + let errors = h.parts.filter(part => part.type === "error"); + expect(errors).toHaveLength(1); + expect(String(errors[0]!.error)).toContain("observer failed"); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); +}); diff --git a/apps/server/src/conversation-plan/prose-job-anchor.test.ts b/apps/server/src/conversation-plan/prose-job-anchor.test.ts new file mode 100644 index 00000000..3761a0b5 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job-anchor.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { proseInput, prosePrompt } from "./prose-job"; +import { CARD, OPTION, QUESTION } from "./prose-job.test-fixtures"; + +test("repeated saved choice after a failed prose job never reuses the prior rationale", async () => { + let prior = "Sentry grouped exceptions and preserved stack traces."; + let context = await openPlan(`${prior}\n\nFollow-up.\n`); + try { + let { plan } = context; + let newOption = "01K0N4W3B7P27CBAEC7A8C8WEB"; + plan.records.set( + CARD, + Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Monitoring", + question: "Which monitoring approach?", + multiple: false, + options: [ + { id: OPTION, label: "Sentry", description: "" }, + { id: newOption, label: "OpenTelemetry with Grafana", description: "" }, + ], + }], + }, + status: "answered", + origin: "conversation", + threadId: "thread-a", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [newOption], + history: [{ choices: [OPTION], owner: "mina", at: 1_758_644_000 }], + prose: [room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!)], + }), + ); + plan.conversationPlan.threads = [{ + id: "thread-a", + questionnaireId: CARD, + question: "Which monitoring approach?", + questionAuthoring: "quoted", + status: "decided", + questionSources: [], + contributions: [ + { + kind: "reason", + text: "Exception grouping and stack traces", + targetId: OPTION, + sources: [], + }, + { kind: "reason", text: "Grafana is already available", targetId: newOption, sources: [] }, + { + kind: "reason", + text: "Keep operational ownership clear", + targetId: "thread-a", + sources: [], + }, + ], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 1, + }] as never; + let input = proseInput(plan, CARD); + expect(input.chosen).toEqual(["OpenTelemetry with Grafana"]); + expect(input.reasons).toEqual([ + "Grafana is already available", + "Keep operational ownership clear", + ]); + let prompt = prosePrompt(input); + expect(prompt).toContain("Grafana is already available"); + expect(prompt).toContain("Keep operational ownership clear"); + expect(prompt).not.toContain("Exception grouping"); + expect(prompt).not.toContain("stack traces"); + // B's prose job can fail, leaving A's anchored paragraph through a second B save. + let saved = plan.records.get(CARD)!; + plan.records.set(CARD, { + ...saved, + history: [...saved.history, { choices: [newOption], owner: "mina", at: 1_758_645_000 }], + }); + let repeated = prosePrompt(proseInput(plan, CARD)); + expect(repeated).not.toContain(prior); + } finally { + await Service.close(context.plan); + } +}); + +test("same choice IDs and changed legacy text answers never use old prose as evidence", async () => { + let context = await openPlan("Existing rationale.\n"); + try { + let { plan } = context; + let second = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let base = { + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Monitoring", + question: "Which monitoring approach?", + multiple: true, + options: [ + { id: OPTION, label: "Sentry", description: "" }, + { id: second, label: "Grafana", description: "" }, + ], + }], + }, + status: "answered", + origin: "conversation", + threadId: "thread-a", + owner: "mina", + decidedAt: 1_758_645_000, + prose: [room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!)], + }; + plan.records.set( + CARD, + Questions.normalizeRecord({ + ...base, + choices: [OPTION, second], + history: [{ choices: [second, OPTION], owner: "mina", at: 1_758_644_000 }], + }), + ); + let sameChoice = prosePrompt(proseInput(plan, CARD)); + expect(sameChoice).toContain("Sentry; Grafana"); + expect(sameChoice).not.toContain("Existing rationale."); + plan.records.set( + CARD, + Questions.normalizeRecord({ + ...base, + choices: [], + answers: { [QUESTION]: "OpenTelemetry" }, + history: [{ + choices: [], + answers: { [QUESTION]: "Sentry" }, + owner: "mina", + at: 1_758_644_000, + }], + }), + ); + let changedText = prosePrompt(proseInput(plan, CARD)); + expect(changedText).toContain("Chosen: OpenTelemetry"); + expect(changedText).not.toContain("Existing rationale."); + } finally { + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/conversation-plan/prose-job.test-fixtures.ts b/apps/server/src/conversation-plan/prose-job.test-fixtures.ts new file mode 100644 index 00000000..9b974ef1 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.test-fixtures.ts @@ -0,0 +1,19 @@ +export const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +export const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +export const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export function record(overrides: Record = {}) { + return { + id: CARD, + origin: "conversation", + threadId: "thread-a", + status: "answered", + owner: "mina", + decidedAt: 1_758_645_000, + definition: { questions: [] }, + history: [], + optionOrigins: {}, + editors: [], + ...overrides, + } as never; +} diff --git a/apps/server/src/conversation-plan/prose-job.test.ts b/apps/server/src/conversation-plan/prose-job.test.ts new file mode 100644 index 00000000..d4b566b6 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { appendProseEffect, proseIntent, prosePrompt } from "./prose-job"; +import { CARD, record } from "./prose-job.test-fixtures"; + +test("proseIntent uses the immutable Save generation, even for same-second re-decisions", () => { + expect(proseIntent(record())).toEqual({ + kind: "prose", + target: CARD, + trigger: `decided:${CARD}:1`, + }); + expect(proseIntent(record({ history: [{ choices: [], owner: "mina", at: 1_758_645_000 }] }))) + .toMatchObject({ trigger: `decided:${CARD}:2` }); + expect(proseIntent(record({ status: "reopened" }))).toBeUndefined(); + expect(proseIntent(record({ origin: "planner" }))).toBeUndefined(); + expect(proseIntent(record({ owner: undefined }))).toBeUndefined(); +}); + +test("appendProseEffect is bounded and idempotent for one saved generation", () => { + let first = appendProseEffect([], [], record()); + expect(first).toMatchObject([{ + key: `job:prose:${CARD}:1`, + kind: "job", + threadId: "thread-a", + intent: { kind: "prose", target: CARD, trigger: `decided:${CARD}:1` }, + }]); + expect(appendProseEffect(first, [], record())).toEqual(first); + expect(appendProseEffect([], [first[0]!.key], record())).toEqual([]); + expect(() => + appendProseEffect( + Array.from({ length: 1024 }, (_, index) => ({ + key: `dummy:${index}`, + kind: "job", + intent: { kind: "heading", target: "document", trigger: `m${index}` }, + })), + [], + record(), + ) + ).toThrow(/outbox is full/); +}); + +test("prosePrompt stays bounded, grounded, and names only its own writing tool", () => { + let prompt = prosePrompt({ + id: CARD, + question: "Which authentication approach?", + chosen: ["GitHub Apps"], + owner: "mina", + involved: ["mina", "jules"], + reasons: ["We need an organisation-level sign-in flow."], + }); + expect(prompt).toContain("[Background job: prose]"); + expect(prompt).toContain(`Decision card id: ${CARD}`); + expect(prompt).toContain("GitHub Apps"); + expect(prompt).toContain("replacement"); + expect(prompt).toContain("not evidence for this choice"); + expect(prompt).toContain("organisation-level sign-in flow"); + expect(prompt).toMatch(/do not add commitments/i); + expect(prompt).toContain("write_decision_prose"); + expect(prompt).toContain("Use no other writing tool."); + expect(prompt).not.toContain("making it a GitHub app"); + expect( + prosePrompt({ + id: CARD, + question: "Q".repeat(1000), + chosen: Array.from({ length: 30 }, () => "L".repeat(300)), + owner: "O".repeat(1000), + involved: Array.from({ length: 30 }, () => "I".repeat(300)), + reasons: Array.from({ length: 30 }, () => "R".repeat(1000)), + }).length, + ).toBeLessThan(9000); +}); diff --git a/apps/server/src/conversation-plan/prose-job.ts b/apps/server/src/conversation-plan/prose-job.ts new file mode 100644 index 00000000..da3ccce6 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.ts @@ -0,0 +1,122 @@ +/** A saved conversation decision's durable job identity and bounded writing context. */ + +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import * as Questions from "../questions/card-involved"; +import { type Effect, type JobIntent, MAX_EFFECTS } from "./effects"; + +import type { Plan } from "../plan/service"; +import type { Record as CardRecord } from "../questions/records"; + +export function proseIntent(record: CardRecord): JobIntent | undefined { + if ( + record.origin !== "conversation" || !record.threadId || record.status !== "answered" + || !record.owner || record.decidedAt === undefined + ) return; + return { + kind: "prose", + target: record.id, + trigger: proseJobTrigger(record.id, decisionGeneration(record)), + }; +} + +/** Save adds this to the same candidate as its answered record and mirror action. */ +export function appendProseEffect( + pending: readonly Effect[], + receipts: readonly string[], + record: CardRecord, +): Effect[] { + let intent = proseIntent(record); + if (!intent) return [...pending]; + let effect: Effect = { + key: `job:prose:${record.id}:${decisionGeneration(record)}`, + kind: "job", + threadId: record.threadId, + intent, + }; + if (receipts.includes(effect.key)) return [...pending]; + let existing = pending.find(item => item.key === effect.key); + if (existing) { + if ( + existing.kind !== "job" || existing.threadId !== effect.threadId + || existing.intent.kind !== intent.kind || existing.intent.target !== intent.target + || existing.intent.trigger !== intent.trigger + ) throw new Error("conversation effect key collision"); + return [...pending]; + } + if (pending.length >= MAX_EFFECTS) throw new Error("conversation effect outbox is full"); + return [...pending, effect]; +} + +export type ProsePromptInput = { + id: string; + question: string; + chosen: string[]; + owner: string; + involved: string[]; + reasons: string[]; +}; + +function quote(value: string, max: number): string { + return value.replace(/\s+/g, " ").trim().slice(0, max); +} + +export function prosePrompt(input: ProsePromptInput): string { + let others = input.involved.filter(handle => handle !== input.owner).slice(0, 8); + let lines = [ + "[Background job: prose]", + "A person saved a decision. Write exactly one grounded paragraph in the document's voice.", + "", + `Decision card id: ${quote(input.id, 200)}`, + `Question: ${quote(input.question, 500)}`, + `Chosen: ${input.chosen.slice(0, 10).map(value => quote(value, 200)).join("; ")}`, + `Saved by: ${quote(input.owner, 80)}${ + others.length ? `; discussed with ${others.map(value => quote(value, 80)).join(", ")}` : "" + }`, + ]; + if (input.reasons.length) { + lines.push( + "Reasons given in the discussion:", + ...input.reasons.slice(0, 12).map(reason => `- ${quote(reason, 300)}`), + ); + } + lines.push( + "", + "An earlier decision paragraph may still appear in `read_plan`. It is a replacement", + "target, not evidence for this choice. Do not reuse its claims or reasons unless the", + "current saved choice and applicable discussion reasons above support them.", + "State only the saved choice and supported reasons. Do not add commitments, implementation", + "details, or claims that the conversation did not establish. Do not name people.", + "One ordinary paragraph, at most 600 characters. Call `read_plan` for its revision, then", + "call `write_decision_prose` once with that revision, the card id, and the paragraph.", + "Use no other writing tool. Do not reply in chat.", + ); + return lines.join("\n"); +} + +export function proseInput(plan: Plan, id: string): ProsePromptInput { + let record = plan.records.get(id); + if (!record || !proseIntent(record)) throw new Error("decision is no longer saved"); + let question = record.definition.questions[0]; + let selected = new Set(record.choices ?? []); + let chosen: string[] = []; + for (let item of record.definition.questions) { + let options = item.options.filter(option => selected.has(option.id)); + if (options.length) chosen.push(...options.map(option => option.label)); + else if (record.answers?.[item.id]) chosen.push(record.answers[item.id]!); + } + let thread = plan.conversationPlan.threads.find(item => item.questionnaireId === id); + let reasons = thread?.contributions + .filter(item => + item.kind === "reason" && (!item.targetId || item.targetId === thread.id + || selected.has(item.targetId)) + ) + .map(item => item.text) ?? []; + return { + id, + question: question?.question ?? "", + chosen, + owner: record.owner!, + involved: Questions.involved(plan, record), + reasons, + }; +} diff --git a/apps/server/src/conversation-plan/question-builder.test-fixtures.ts b/apps/server/src/conversation-plan/question-builder.test-fixtures.ts new file mode 100644 index 00000000..2b7efaa9 --- /dev/null +++ b/apps/server/src/conversation-plan/question-builder.test-fixtures.ts @@ -0,0 +1,75 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { extractQuotes } from "./quotes"; + +// Exact archive data/helpers: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/questions.test.ts. +export let text = + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent’s repository credentials, we could pass each user’s GitHub token or use a GitHub App installation token. Those are two different calls."; +export let message: Chat.Entry = { + id: "D19-m2", + text, + author: { kind: "member", handle: "Omar" }, + ts: 1_000, +}; +export let quotes = extractQuotes(text); + +export let fourCandidates = () => { + let text = [ + "Use GitHub OAuth", + "Use email magic links", + "Use the GitHub App if it supports SSO", + "Keep repository tokens separate", + ].join("; "); + let candidates = [ + "Use GitHub OAuth", + "Use email magic links", + "Use the GitHub App if it supports SSO", + "Keep repository tokens separate", + ].map(quote => { + let start = text.indexOf(quote); + return { quote, start, end: start + quote.length }; + }); + return { + current: { ...message, id: "four-source-spans", text }, + candidates, + }; +}; + +export let threadWithStatus = ( + status: ConversationPlan.Thread["status"], +): ConversationPlan.Thread => ({ + id: "auth-thread", + question: "How should people sign in?", + questionSources: [], + questionAuthoring: "quoted", + status, + contributions: [], + stances: [], + stanceHistory: [], + decision: status === "decided" + ? { + id: "decision", + text: "Use GitHub OAuth", + sources: [], + actor: { kind: "member", handle: "Mina" }, + at: 1_000, + } + : undefined, + decisionHistory: [], + candidates: [], + version: 1, +}); + +export let cacheText = "🧪 Use Redis for API caching; use Valkey for background jobs."; +export let cacheMessage: Chat.Entry = { + id: "cache-options", + text: cacheText, + author: { kind: "member", handle: "Ari" }, + ts: 2_001, +}; +export let cacheQuotes = extractQuotes(cacheText); +export let emptyCacheThread = { + ...threadWithStatus("exploring"), + id: "api-cache", + question: "How should we cache API responses?", +}; diff --git a/apps/server/src/conversation-plan/question-context.ts b/apps/server/src/conversation-plan/question-context.ts new file mode 100644 index 00000000..242780ee --- /dev/null +++ b/apps/server/src/conversation-plan/question-context.ts @@ -0,0 +1,160 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { LinkedCardOptions } from "./question-shared"; +import { effectivePending } from "./preference"; + +export type VisibleThread = { + thread: ConversationPlan.Thread; + options: ConversationPlan.Contribution[]; +}; + +export function visibleThreads( + threads: readonly ConversationPlan.Thread[], + events: readonly ConversationPlan.Event[] = [], +): VisibleThread[] { + let ordered = [ + ...threads.filter((thread) => thread.status !== "discarded"), + ...threads.filter((thread) => thread.status === "discarded"), + ]; + if (ordered.length > 12) { + let touched = new Map(); + events.forEach((event, index) => { + touched.set(event.threadId, index); + if (event.type === "card.corrected" && event.change.kind === "move") { + touched.set(event.change.targetThreadId, index); + } + }); + let selected = new Set( + ordered.map((thread, index) => ({ thread, index })) + .sort((a, b) => + Number(a.thread.status === "discarded") - Number(b.thread.status === "discarded") + || (touched.get(b.thread.id) ?? -1) - (touched.get(a.thread.id) ?? -1) + || b.index - a.index + ).slice(0, 12).map(({ thread }) => thread.id), + ); + ordered = ordered.filter(thread => selected.has(thread.id)); + } + let available = ordered.map((thread) => + thread.contributions.filter((item) => item.kind === "option").slice(-8).reverse() + ); + let visible = ordered.map((thread) => ({ + thread, + options: [] as ConversationPlan.Contribution[], + })); + let count = 0; + for (let depth = 0; depth < 8 && count < 32; depth++) { + for (let index = 0; index < ordered.length && count < 32; index++) { + let option = available[index][depth]; + if (!option) continue; + visible[index].options.push(option); + count++; + } + } + return visible; +} + +export function cardChoices( + selected: readonly VisibleThread[], + linkedCards: LinkedCardOptions, +): Record { + let choices: Record = {}; + for (let { thread } of selected) { + let card = linkedCards.get(thread.id); + if (!card || card.cardId !== thread.questionnaireId) continue; + for (let option of card.options) { + if (["new", "none"].includes(option.id)) throw new Error("reserved card option ID"); + choices[option.id] = `${option.label.slice(0, 100)} (in ${thread.question.slice(0, 100)})`; + } + } + return choices; +} + +export function compactThreads( + selected: readonly VisibleThread[], + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +) { + return selected.map(({ thread, options }) => { + let pending = effectivePending(thread, events); + let pendingOption = pending + && thread.contributions.find((item) => item.id === pending.optionId); + let card = linkedCards.get(thread.id); + let visibleOptions = options.map((item) => ({ + id: item.id, + text: (item.displayLabel ?? item.text).slice(0, 100), + })); + if (card && card.cardId === thread.questionnaireId) { + for (let option of card.options) { + if (!visibleOptions.some(item => item.id === option.id)) { + visibleOptions.push({ id: option.id, text: option.label.slice(0, 100) }); + } + } + } + return { + id: thread.id, + question: thread.question.slice(0, 160), + status: thread.status, + options: visibleOptions, + contributions: thread.contributions.filter((item) => item.kind !== "option") + .sort((a, b) => Number(Boolean(b.targetEditedBy)) - Number(Boolean(a.targetEditedBy))) + .slice(0, 4) + .map((item) => ({ + id: item.id, + text: item.text.slice(0, 100), + targetId: item.targetId, + relation: item.relation, + })), + stances: thread.stances.slice(-6).map((item) => ({ + participant: item.participant, + optionId: item.optionId, + position: item.position, + })), + decision: thread.decision?.text.slice(0, 160), + pendingSettle: pending && { + optionId: pending.optionId, + option: pendingOption + && (pendingOption.displayLabel ?? pendingOption.text).slice(0, 100), + proposer: pending.proposer, + }, + }; + }); +} + +export function fitState< + T extends { + threads: ReturnType; + recent: Array<{ text: string }>; + }, +>(state: T): T { + let withinBudget = () => JSON.stringify(state).length <= 23_500; + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.contributions = thread.contributions.slice(0, 1); + thread.stances = thread.stances.slice(-2); + thread.decision = thread.decision?.slice(0, 80); + } + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.contributions = []; + thread.stances = []; + thread.decision = undefined; + } + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.question = thread.question.slice(0, 80); + for (let option of thread.options) option.text = option.text.slice(0, 40); + } + while (!withinBudget() && state.recent.length) state.recent.shift(); + if (!withinBudget()) throw new Error("conversation context exceeds Jev state budget"); + return state; +} + +export function threadChoices(selected: readonly VisibleThread[]): Record { + let choices: Record = {}; + for (let { thread } of selected) { + if (["new", "none"].includes(thread.id)) throw new Error("reserved conversation thread ID"); + choices[thread.id] = `${thread.question.slice(0, 160)} (${thread.status})`; + } + choices.new = "A distinct planning question not represented by a current thread."; + choices.none = "No planning thread is clearly referred to; do not guess from proximity alone."; + return choices; +} diff --git a/apps/server/src/conversation-plan/question-interpreter.test.ts b/apps/server/src/conversation-plan/question-interpreter.test.ts new file mode 100644 index 00000000..b82838de --- /dev/null +++ b/apps/server/src/conversation-plan/question-interpreter.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import { interpretMessage } from "./interpret"; +import type { JevAnswer, JevRequest } from "./jev"; +import { cacheMessage, cacheQuotes, emptyCacheThread } from "./question-builder.test-fixtures"; + +// Whole final outer loop from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +for (let hasPrior of [false, true]) { + test(`interpretation ${hasPrior ? "accepts true" : "omits unasked"} duplicate evidence`, async () => { + let requests: JevRequest[] = []; + let earlier: Chat.Entry = { + ...cacheMessage, + id: "cache-earlier-option", + text: cacheQuotes[0]!.quote, + ts: cacheMessage.ts - 1, + }; + let thread: ConversationPlan.Thread = hasPrior + ? { + ...emptyCacheThread, + contributions: [{ + id: "cache-redis-option", + kind: "option", + text: cacheQuotes[0]!.quote, + authoring: "quoted", + sources: [{ + messageId: earlier.id, + author: { kind: "member", handle: "Ari" }, + quote: earlier.text, + start: 0, + end: earlier.text.length, + role: "option", + }], + actor: { kind: "classifier" }, + }], + } + : emptyCacheThread; + let interpreted = await interpretMessage({ + channelId: "engineering-cache", + message: cacheMessage, + recent: hasPrior ? [earlier] : [], + state: { ...initialState(), threads: [thread] }, + ask: async request => { + requests.push(request); + let answers = Object.fromEntries( + Object.entries(request.questions).map(([key, question]) => { + let answer: JevAnswer; + if (question.type === "noul") { + answer = { + type: "noul", + noul: key === "new_option" || key.endsWith("_owned_unretracted") + || hasPrior && key === "c0_duplicate" + ? 0.95 + : 0.05, + }; + } else if (question.type === "score") { + answer = { + type: "score", + score: 2, + confidence: 1, + legend: Object.fromEntries(question.criteria.map((label, index) => [index, label])), + probabilities: { "0": 0, "1": 0, "2": 1, "3": 0 }, + }; + } else { + let preferred = key === "act" + ? "proposal" + : key === "thread_target" + ? "api-cache" + : key.endsWith("_role") + ? "option" + : key.endsWith("_thread") + ? "api-cache" + : key.endsWith("_option") + ? "new" + : "none"; + let selected = preferred in question.criteria + ? preferred + : Object.keys(question.criteria)[0]!; + answer = { + type: "choice", + choice: selected, + confidence: 1, + probabilities: Object.fromEntries( + Object.keys(question.criteria).map(value => [ + value, + Number(value === selected), + ]), + ), + }; + } + return [key, answer]; + }), + ); + return { + model: "fake-jev", + answers, + usage: { input_tokens: 0, output_tokens: 0 }, + latencyMs: 0, + }; + }, + }); + let targeting = requests.filter(request => !("new_question" in request.questions)); + expect(targeting).toHaveLength(2); + let pass = interpreted.analysis.passes.find(item => item.stage === "targeting"); + if (hasPrior) { + expect(targeting[0]?.questions.c0_duplicate?.type).toBe("noul"); + expect(pass?.answers.c0_duplicate).toEqual({ type: "noul", noul: 0.95 }); + } else { + expect( + targeting.every(request => + Object.keys(request.questions).every(key => !key.endsWith("_duplicate")) + ), + ).toBe(true); + expect(pass?.answers.c0_duplicate).toBeUndefined(); + expect(pass?.answers.c1_duplicate).toBeUndefined(); + } + }); +} diff --git a/apps/server/src/conversation-plan/question-research.ts b/apps/server/src/conversation-plan/question-research.ts new file mode 100644 index 00000000..d287b978 --- /dev/null +++ b/apps/server/src/conversation-plan/question-research.ts @@ -0,0 +1,123 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; +import { compactThreads, fitState, visibleThreads } from "./question-context"; +import { noul } from "./question-shared"; + +export function buildResearchOfferRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + events: readonly ConversationPlan.Event[] = [], +): JevRequest { + assertQuoteBudget(candidates); + let selected = visibleThreads(threads, events).filter(({ thread, options }) => + ["exploring", "leaning", "reopened"].includes(thread.status) + && [2, 3, 4].includes(thread.contributions.filter(item => item.kind === "option").length) + && options.length === thread.contributions.filter(item => item.kind === "option").length + ); + if (!candidates.length || !selected.length) throw new Error("no grounded research candidates"); + let quoteCriteria: Record = {}; + let ownershipQuestions: Record = {}; + for (let index = 0; index < candidates.length; index++) { + quoteCriteria[`q${index}`] = candidates[index]!.quote.slice(0, 200); + ownershipQuestions[`research_q${index}_owned`] = noul( + `Is candidates[${index}].quote the current speaker's own sincere, unconditional, still-current external cost concern? Judge this exact span in all of current.text. Exclude quoted or reported words, sarcasm, hypotheticals, and a concern withdrawn later.`, + "Direct speaker-owned concern.", + "Quoted, conditional, reported, withdrawn, or unclear concern.", + ); + ownershipQuestions[`research_q${index}_answered`] = noul( + `Has the current external cost concern in candidates[${index}].quote already been answered anywhere in current.text, recent, or threads, so fresh outside research is unnecessary? A local volume estimate alone is not a current provider price answer.`, + "The current costs have already been answered.", + "The current costs remain unknown.", + ); + ownershipQuestions[`research_q${index}_scope`] = { + type: "choice", + instructions: + `For candidates[${index}].quote alone, which existing option does its current external cost concern uniquely name? Choose all-current only for a generic provider-price unknown naming no option. Choose none for an unclear, foreign, or multi-option concern. Do not infer a pair.`, + criteria: { + "all-current": "Generic current provider-price unknown without a named option.", + ...Object.fromEntries(selected.flatMap(({ options }) => + options.map(option => [ + option.id, + (option.displayLabel ?? option.text).slice(0, 100), + ]) + )), + none: "No unique current option or grounded generic concern.", + }, + }; + } + quoteCriteria.none = "No exact speaker-owned quote states the current external cost concern."; + let threadCriteria: Record = {}; + let optionCriteria: Record = {}; + for (let { thread, options } of selected) { + if (thread.id === "none" || thread.id === "new") continue; + threadCriteria[thread.id] = thread.question.slice(0, 160); + for (let option of options) { + if (option.id === "none" || option.id === "new" || option.id in optionCriteria) continue; + optionCriteria[option.id] = `${(option.displayLabel ?? option.text).slice(0, 100)} (in ${ + thread.question.slice(0, 100) + })`; + } + } + if (!Object.keys(threadCriteria).length || Object.keys(optionCriteria).length < 2) { + throw new Error("no grounded research options"); + } + threadCriteria.none = "No single active thread clearly owns this comparison."; + optionCriteria.none = "No single existing option is clearly relevant."; + let optionQuestion = (side: string): JevQuestion => ({ + type: "choice", + instructions: + `Select ${side} of exactly two distinct existing options whose current external costs need comparison. Choose none if no clear option. Do not create an option or infer a new provider.`, + criteria: optionCriteria, + }); + return { + state: fitState({ + current: { id: message.id, author: message.author, text: message.text.slice(0, 4000) }, + candidates: [...candidates], + recent: recent.filter(entry => entry.id !== message.id).slice(-12).map(entry => ({ + id: entry.id, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events).map((item, index) => ({ + ...item, + options: item.options.map(option => ({ + ...option, + text: (selected[index]?.options.find(value => value.id === option.id)?.displayLabel + ?? option.text).slice(0, 100), + })), + })), + }), + questions: { + ...ownershipQuestions, + research_quote: { + type: "choice", + instructions: + "Which exact candidates quote is the speaker's direct, still-current external cost concern? Choose none for quotation, report, hypothetical, ambiguity, or no suitable span.", + criteria: quoteCriteria, + }, + research_thread: { + type: "choice", + instructions: + "Which one active planning thread owns the current external cost concern? Choose none if ambiguous or already settled.", + criteria: threadCriteria, + }, + research_option_a: optionQuestion("the first"), + research_option_b: optionQuestion("the second"), + research_kind: { + type: "choice", + instructions: + "Does current.text itself raise a need for fresh outside information about current costs in one active thread? Choose comparison only for two existing options; choose concern for a generic price unknown or one-option cost concern among three or four current options. Exclude local volume estimates, general tradeoffs, and already available facts. Do not rely on answers to other questions.", + criteria: { + "current-cost-comparison": + "Fresh outside pricing or cost information for both options is needed.", + "current-cost-concern": + "Fresh outside cost information for a three- or four-option context is needed, with at most one named focus.", + none: "No current external cost comparison is warranted.", + }, + }, + }, + }; +} diff --git a/apps/server/src/conversation-plan/question-shared.ts b/apps/server/src/conversation-plan/question-shared.ts new file mode 100644 index 00000000..29810b54 --- /dev/null +++ b/apps/server/src/conversation-plan/question-shared.ts @@ -0,0 +1,31 @@ +import type { JevQuestion } from "./jev"; + +export const QUESTION_SET_VERSION = "conversation-plan-8"; + +export const FOUR_CANDIDATE_QUESTIONS = new Set([ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "support", + "objection", + "duplicate", + "explicit_resolution", +]); + +/** Schedule a dedicated judgment only for wording that can directly retract a stance. */ +export function hasWithdrawalCue(quote: string): boolean { + return /\b(?:withdraw|retract|take\s+back|no\s+longer|chang(?:e|ed)\s+my\s+mind|not\s+going\s+with|scratch\s+that)\b/i + .test(quote); +} + +export type LinkedCardOptions = ReadonlyMap; +}>; + +export function noul(instructions: string, yes: string, no: string): JevQuestion { + return { type: "noul", instructions, criteria: { true: yes, false: no } }; +} diff --git a/apps/server/src/conversation-plan/question-targeting.ts b/apps/server/src/conversation-plan/question-targeting.ts new file mode 100644 index 00000000..73db2a04 --- /dev/null +++ b/apps/server/src/conversation-plan/question-targeting.ts @@ -0,0 +1,322 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import { isExplicitCompoundDecision, type QuoteCandidate } from "./quotes"; +import { assertQuoteBudget, MAX_QUOTE_CANDIDATES } from "./quote-budget"; +import { effectivePending } from "./preference"; +import { + cardChoices, + compactThreads, + fitState, + threadChoices, + visibleThreads, +} from "./question-context"; +import { FOUR_CANDIDATE_QUESTIONS, hasWithdrawalCue, noul } from "./question-shared"; +import type { LinkedCardOptions } from "./question-shared"; + +export function buildTargetingRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + focusIndex?: number, + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +): JevRequest { + assertQuoteBudget(candidates); + let focus = focusIndex === undefined ? undefined : candidates[focusIndex]; + let compoundDecision = !!focus && isExplicitCompoundDecision(message.text, candidates); + if ( + focusIndex !== undefined && (!focus || focusIndex < 0 || focusIndex >= MAX_QUOTE_CANDIDATES + || message.text.slice(focus.start, focus.end) !== focus.quote) + ) { + throw new Error("invalid targeting candidate"); + } + let questions: Record = {}; + let selected = visibleThreads(threads, events); + // The service supplies recent as the chat prefix before this message. Source-free + // contributions cannot be ordered safely when an older message is retried. + let earlierMessageIds = new Set( + recent.filter(entry => + entry.id !== message.id && entry.ts <= message.ts + && entry.author.kind !== "system" && !entry.streaming + ).map(entry => entry.id), + ); + let priorContributions = selected.flatMap(({ thread }) => + thread.contributions.filter(contribution => + contribution.text.trim() && contribution.sources.length > 0 + && contribution.sources.every(source => + source.messageId !== message.id && earlierMessageIds.has(source.messageId) + ) + ).map(contribution => ({ + id: contribution.id, + text: contribution.text.slice(0, 300), + })) + ).slice(-8); + let threadCriteria = threadChoices(selected); + let pending = selected.flatMap(({ thread }) => { + let proposal = effectivePending(thread, events); + return proposal ? [{ thread, proposal }] : []; + }); + for (let { thread, proposal } of pending) { + let option = thread.contributions.find(item => item.id === proposal.optionId); + if (!option) continue; + threadCriteria[thread.id] += ` Pending proposal to settle on "${ + option.text.slice(0, 100) + }" by ${proposal.proposer.slice(0, 80)}.`; + } + let solePending = pending.length === 1 ? pending[0] : undefined; + let soleOption = solePending?.thread.contributions.find(item => + item.id === solePending.proposal.optionId + ); + let optionCriteria: Record = {}; + for (let { thread, options } of selected) { + for (let option of options) { + if (["new", "none"].includes(option.id)) throw new Error("reserved conversation option ID"); + optionCriteria[option.id] = `${option.text.slice(0, 100)} (in ${ + thread.question.slice(0, 100) + })`; + } + } + Object.assign(optionCriteria, cardChoices(selected, linkedCards)); + optionCriteria.new = "A distinct option that does not yet exist."; + optionCriteria.none = "No specific option can be identified."; + for (let index = 0; index < candidates.length; index++) { + let path = `candidates[${index}].quote`; + let prefix = `c${index}_`; + questions[`${prefix}role`] = { + type: "choice", + instructions: + `What single planning role does ${path} play as this speaker's own assertion? Do not mistake a quotation, reported agreement, sarcasm, or assent for a proposal to settle.` + + (compoundDecision + ? " Use current.fullMessage only to assess the speaker's intent and ownership; classify this exact span." + : ""), + criteria: { + question: "New unresolved planning question.", + option: "Distinct proposed course of action.", + reason: "Reason or evidence for a plan.", + constraint: "Requirement or limitation.", + support: "Speaker's support for an existing option.", + objection: "Speaker's objection to an option or decision.", + resolution: "Speaker proposes settling on one definite option now.", + reopening: "Speaker explicitly requests reopening a decision.", + none: "No useful atomic planning contribution, or unclear/quoted/sarcastic content.", + }, + }; + questions[`${prefix}thread`] = { + type: "choice", + instructions: + `Which planning thread does ${path} address? Judge this exact quote; later clauses of current.text may address a different thread. A short assent or direct withdrawal may target a pending proposal to settle if the quote and preceding context clearly refer to it. Use new for a distinct question; none if unclear.`, + criteria: threadCriteria, + }; + questions[`${prefix}option`] = { + type: "choice", + instructions: + `Which existing option does ${path} discuss, support, oppose, or withdraw a proposal for? A direct withdrawal may target the specific pending option through preceding context. A resolution may address a rejected option; this question does not identify the chosen option. Use new for a distinct option or none if unclear.`, + criteria: optionCriteria, + }; + questions[`${prefix}chosen_option`] = { + type: "choice", + instructions: + `If ${path} proposes settling a team choice, which option does the speaker positively choose? Do not select an option the speaker rejects, negates, or merely mentions. Select new only for a clearly proposed plan absent from the listed options; select none for no clear chosen option.`, + criteria: { + ...Object.fromEntries( + Object.entries(optionCriteria).filter(([key]) => key !== "new" && key !== "none"), + ), + new: "A clearly adopted plan not represented by an existing option.", + none: "No explicit chosen option; only a rejected/quoted option or unclear resolution.", + }, + }; + questions[`${prefix}relation`] = { + type: "choice", + instructions: `How does ${path} relate to its target?`, + criteria: { + supports: "Supports it.", + challenges: "Challenges it.", + qualifies: "Adds a condition.", + replaces: "Explicitly supersedes it.", + unrelated: "No clear relation.", + }, + }; + questions[`${prefix}new_option`] = noul( + `Does ${path} itself propose a distinct plan option? Judge this exact quote only, not other clauses in current.text.`, + "A concrete distinct course of action in this quote.", + "No new option in this quote, or merely chatter/assent.", + ); + questions[`${prefix}planning_substance`] = noul( + `Does ${path} itself contain a substantive planning reason, requirement, or concern? Judge only this quote; vague deictic chatter without a clear plan point is false.`, + "A specific reason, requirement, or concern in this quote.", + "No specific planning substance in this quote.", + ); + questions[`${prefix}support`] = noul( + `Does the current speaker personally support an option in ${path}? A quote, reported stance, or sarcasm is false.`, + "This quote expresses the speaker's own support.", + "No personal support in this quote.", + ); + questions[`${prefix}objection`] = noul( + `Does the current speaker personally object to a plan in ${path}? A quote, report, or sarcasm is false.`, + "This quote expresses the speaker's own plan objection.", + "No personal plan objection in this quote.", + ); + if (priorContributions.length) { + questions[`${prefix}duplicate`] = noul( + `Compare only ${path} with state.priorContributions. Is this exact focused quote merely a duplicate of one of those earlier contributions, with no new substantive detail? Do not compare other spans of the current message or preceding context.`, + "Duplicate of a named prior contribution.", + "New detail or distinct contribution.", + ); + } + questions[`${prefix}explicit_resolution`] = noul( + `Does ${path} itself propose that the team settle on one definite option now, such as 'let's just go with X' or 'we decided X'? Judge only this speaker's own assertion. A question, reported or quoted decision, hedged preference, or sarcasm is false.`, + "Proposal to settle now.", + "No proposal to settle.", + ); + if (pending.length) { + let referent = solePending && soleOption + ? `the pending proposal by ${solePending.proposal.proposer.slice(0, 80)} to settle on "${ + soleOption.text.slice(0, 100) + }" in the "${solePending.thread.question.slice(0, 160)}" thread` + : "a clearly identified pending proposal in threads[].pendingSettle"; + questions[`${prefix}agrees_with_settle`] = noul( + `Does ${path} itself agree with ${referent}? Judge this exact quote and preceding context, not later clauses of current.text. Plain assent counts when it clearly follows that proposal; quoted or reported assent, sarcasm, and agreement with a different target do not. If several proposals are pending, require a clear referent.`, + "The speaker agrees with the pending proposal to settle.", + "No agreement with a pending proposal.", + ); + if (focusIndex !== undefined) { + questions[`${prefix}qualifies_pending_settle`] = noul( + `Does ${path} itself state a specific requirement or condition on a clearly identified pending proposal to settle? Use threads[].pendingSettle for the proposed option, including when this quote names a different option as a fallback. Require a clear link to the proposed option from this quote and preceding context. A condition on an unrelated option, vague concern, quoted or withdrawn condition, or unclear referent is false. If several proposals are pending, require a clear referent.`, + "The speaker adds a specific condition to the identified pending proposal.", + "No specific condition on an identified pending proposal.", + ); + if (hasWithdrawalCue(candidates[index]!.quote)) { + questions[`${prefix}withdraws_pending_settle`] = noul( + `Does ${path} directly and unconditionally take back this speaker's own currently pending proposal to settle on a specific option? Use threads[].pendingSettle and preceding context to identify both the proposer and option. This is distinct from correcting a plan detail or reopening an already decided choice. Quoted, reported, hypothetical, conditional, and other-speaker withdrawals are false; if the proposal or option is unclear, answer false.`, + "The proposer personally withdraws their identified pending option now.", + "No direct withdrawal of that speaker's identified pending option.", + ); + } + } + } + if (threads.some((thread) => thread.status === "discarded")) { + questions[`${prefix}raises_again`] = noul( + `Does ${path} deliberately bring back a question the team already discarded, saying it should be considered again?`, + "The speaker explicitly raises a discarded question again.", + "The quote merely resembles a discarded question.", + ); + } + questions[`${prefix}reopening`] = noul( + `Does ${path} itself directly request reopening a decision?`, + "Direct reopening request.", + "No direct reopening request.", + ); + if ( + !(focusIndex !== undefined && pending.length && hasWithdrawalCue(candidates[index]!.quote)) + ) { + questions[`${prefix}material_objection`] = noul( + `Does ${path} raise a substantive new concern that calls a current decision into question?`, + "Material concern against a current decision.", + "No material challenge to a current decision.", + ); + } + } + let fourCandidateQuestions: Set | undefined; + // Four isolated requests share one durable targeting pass capped at 45 answers. + if (candidates.length === MAX_QUOTE_CANDIDATES) { + fourCandidateQuestions = new Set(); + let include = (index: number, suffix: string) => { + let key = `c${index}_${suffix}`; + if (questions[key]) fourCandidateQuestions!.add(key); + }; + let discarded = selected.some(({ thread }) => thread.status === "discarded"); + let decided = selected.some(({ thread }) => thread.status === "decided"); + let active = selected.some(({ thread }) => + ["exploring", "leaning", "reopened"].includes(thread.status) + ); + if ((discarded || decided) && active) { + throw new Error("four-candidate mixed thread states exceed targeting budget"); + } + let core = discarded + ? [ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "duplicate", + "relation", + ] + : decided + ? [ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "support", + "objection", + "duplicate", + ] + : [...FOUR_CANDIDATE_QUESTIONS, "relation"]; + for (let index = 0; index < candidates.length; index++) { + for (let suffix of core) include(index, suffix); + if (discarded) { + for (let suffix of ["raises_again", "reopening", "material_objection"]) { + include(index, suffix); + } + } else if (decided) { + include(index, "reopening"); + include(index, "material_objection"); + } + } + if (pending.length) { + for (let index = 0; index < candidates.length; index++) { + let quote = candidates[index]!.quote; + let extra = hasWithdrawalCue(quote) + ? "withdraws_pending_settle" + : /\b(?:if|unless|provided)\b/i.test(quote) + ? "qualifies_pending_settle" + : "agrees_with_settle"; + let key = `c${index}_${extra}`; + if (!questions[key]) continue; + if (fourCandidateQuestions.size === 45) { + fourCandidateQuestions.delete(`c${index}_explicit_resolution`); + } + fourCandidateQuestions.add(key); + } + } + if (fourCandidateQuestions.size > 45) throw new Error("too many targeting questions"); + } + return { + state: fitState({ + current: { + id: message.id, + author: message.author, + text: focus ? focus.quote : message.text.slice(0, 4000), + ...(compoundDecision + ? { fullMessage: message.text } + : {}), + }, + ...(focus ? { preceding: message.text.slice(0, focus.start).slice(-300) } : {}), + recent: recent.filter((entry) => entry.id !== message.id).slice(-12).map((entry) => ({ + id: entry.id, + author: entry.author, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events, linkedCards), + priorContributions, + candidates: candidates.slice(0, focusIndex === undefined ? candidates.length : focusIndex + 1) + .map((candidate) => ({ + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + })), + }), + questions: Object.fromEntries( + Object.entries(questions).filter(([key]) => { + if (focusIndex !== undefined && !key.startsWith(`c${focusIndex}_`)) return false; + if (fourCandidateQuestions && !fourCandidateQuestions.has(key)) return false; + return true; + }), + ), + }; +} diff --git a/apps/server/src/conversation-plan/question-triage.ts b/apps/server/src/conversation-plan/question-triage.ts new file mode 100644 index 00000000..a56c4874 --- /dev/null +++ b/apps/server/src/conversation-plan/question-triage.ts @@ -0,0 +1,210 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; +import { compactThreads, fitState, threadChoices, visibleThreads } from "./question-context"; +import { noul } from "./question-shared"; + +export function triageQuestions(): Record { + return { + new_question: noul( + "Does current.text itself put forward a distinct unresolved planning decision or question for the group to answer? Require a concrete choice, tradeoff, or specific unknown that could be answered, even when phrased as a statement. A message merely announcing intent to figure out a topic or start planning is purpose, not a new question, even if no solution exists yet. Do not invent a decision from the broad topic.", + "A concrete open decision or answerable question is raised, including a declarative choice between approaches.", + "Only intent or a topic for planning is stated; no distinct question to resolve is posed.", + ), + enough_purpose: noul( + "Does current.text, with recent messages, name a concrete planning topic or problem and an intent to plan or choose an approach? A topic-level statement like 'we need to figure out what we are doing for auth' is enough for a broad title and one-sentence goal. Do not require a chosen solution, candidate options, or implementation details. Generic 'we need a plan' without a topic is false.", + "A topic and intent to plan or choose it are clear, even if details are unresolved.", + "There is no identifiable topic or no intent to plan it.", + ), + new_option: noul( + "Does current.text propose or personally name concrete alternatives for an open planning question? A first-person present explanation like 'By X I mean A or B' names two options even without 'should'. Exclude reported, quoted, negated, or withdrawn alternatives.", + "A distinct course of action is proposed or personally named as an alternative.", + "No distinct option; assent alone is not new.", + ), + reason: noul( + "Does current.text give a reason for or against a plan option?", + "A causal or evaluative reason is stated.", + "No reason is stated.", + ), + constraint: noul( + "Does current.text impose a requirement or limitation on a plan?", + "A requirement or limitation is expressed.", + "No plan constraint.", + ), + evidence: noul( + "Does current.text offer evidence relevant to a planning choice?", + "A relevant observation or fact is offered.", + "No evidence claim.", + ), + assumption: noul( + "Does current.text name an assumption behind a planning choice?", + "A relevant assumption is stated.", + "No assumption.", + ), + support: noul( + "Does the current speaker personally support an existing option in current.text?", + "The speaker expresses support, including qualified support or assent.", + "Support is quoted, sarcastic, or absent.", + ), + objection: noul( + "Does the current speaker personally object to a plan in current.text?", + "The speaker expresses a substantive objection.", + "No current objection.", + ), + correction: noul( + "Does current.text correct earlier planning information?", + "The speaker corrects a previously stated plan detail.", + "No correction.", + ), + withdrawal: noul( + "Does current.text directly retract the current speaker's own earlier proposal or preference for a planning option? Require an explicit withdrawal in the speaker's own words; a correction of a detail, quotation, reported speech, hypothetical condition, or another person's withdrawal is false.", + "The speaker explicitly takes back their own earlier proposal or preference.", + "No direct personal withdrawal is stated.", + ), + explicit_resolution: noul( + "Does current.text itself propose that the team settle on one definite option now, such as 'let's just go with X' or 'we decided X'? Judge only this speaker's own assertion. A question, reported or quoted decision, hedged preference, or sarcasm is false.", + "Proposal to settle now.", + "No proposal to settle.", + ), + reopening: noul( + "Does current.text explicitly request reopening an already decided planning choice?", + "A direct request to reopen a settled choice.", + "Concern without explicit reopening, or no reopening.", + ), + planner_request: noul( + "Does current.text directly ask the Planner to act?", + "A direct request to the Planner.", + "No direct Planner request.", + ), + research_need: noul( + "Does the current speaker raise an unresolved need for current external cost or price information about two to four existing planning options? This may be a generic current-provider price unknown or a cost concern focused on one option. Require a useful unknown needing fresh outside research. A quoted, conditional, already answered, or merely local calculation is false. Judge this independently of whether the message adds a planning contribution.", + "Fresh external cost information about existing options is needed.", + "No grounded current external cost information is needed.", + ), + act: { + type: "choice", + instructions: + "What is the current speaker's primary discourse act in current.text? Judge the speaker's own words, not quotations or reported consensus.", + criteria: { + question: + "Raises an unresolved planning question, including a declarative 'we need to choose how ...' or 'we need to decide how ...' without a question mark.", + proposal: + "Suggests a concrete answer, plan, or option; not merely that we need to choose how or that a choice is needed.", + evaluation: "Evaluates, supports, or objects to a plan.", + commitment: "Explicitly settles the team's choice now.", + correction: "Corrects an earlier plan detail.", + other: "Chatter, quotation, report, sarcasm, or another act.", + }, + }, + thread_target: { + type: "choice", + instructions: + "Which one current planning thread does current.text mainly address? If there are no current threads and current.text clearly raises a distinct unanswered planning question, choose new. Use none only when no question or clear target is raised. Do not guess a target from a topic-only purpose statement; multi-claim quotes are targeted separately later.", + criteria: { + new: "An unanswered planning question is raised and no current thread represents it.", + none: "No distinct planning question or clear current-thread target is raised.", + }, + }, + relation: { + type: "choice", + instructions: "How does current.text relate to the planning idea it mentions?", + criteria: { + supports: "Supports it.", + challenges: "Challenges it.", + qualifies: "Adds a condition or nuance.", + replaces: "Supersedes a prior statement.", + unrelated: "No clear relation.", + }, + }, + significance: { + type: "score", + instructions: "Rate the planning significance of current.text, excluding quoted claims.", + criteria: [ + "Chatter or logistical aside", + "Minor related remark", + "Useful planning point", + "Changes proposed work", + ], + }, + commitment: { + type: "score", + instructions: + "How much team commitment does the current speaker personally express in current.text? Do not credit quotations, reported commitments, consensus guesses, or sarcasm.", + criteria: [ + "No team commitment", + "Personal preference or proposal", + "Tentative team agreement", + "Explicit present team resolution", + ], + }, + ambiguity: { + type: "score", + instructions: "How ambiguous is the intended planning meaning of current.text?", + criteria: [ + "Clear", + "Mostly clear", + "Several plausible readings", + "Needs human interpretation", + ], + }, + novelty: { + type: "score", + instructions: + "How novel is the substantive planning point in current.text relative to recent and threads?", + criteria: [ + "Repeated or no point", + "Restates with small nuance", + "Substantive new detail", + "New planning direction", + ], + }, + }; +} + +/** A second judgment selects only existing IDs and one exact saved quote span. */ +export function buildTriageRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[] = [], + events: readonly ConversationPlan.Event[] = [], +): JevRequest { + assertQuoteBudget(candidates); + let questions = triageQuestions(); + for (let index = 0; index < candidates.length; index++) { + questions[`c${index}_owned_unretracted`] = noul( + `Read all of current.text in order. Is candidates[${index}].quote the current speaker's own sincere, still-current contribution? It may be a question, option, reason, concern, support, or resolution; do not require its planning target to be clear. A later independent topic does not cancel it. But a later sentence can reveal that an earlier phrase was another person's words, or can withdraw or contradict the speaker's earlier stance. In those cases the earlier candidate is false even though the speaker typed it. Reported words, quotation, and sarcasm are false. Judge this exact source span rather than the message's overall stance.`, + "The speaker owns this sincere contribution and did not later withdraw it.", + "Attribution, quotation, sarcasm, or an earlier contribution withdrawn later.", + ); + } + let selected = visibleThreads(threads, events); + questions.thread_target = { + type: "choice", + instructions: + "Which one current planning thread does current.text mainly address? If there are no current threads and current.text clearly raises a distinct unanswered planning question, choose new. Use none only when no question or clear target is raised. Do not guess a target from a topic-only purpose statement; multi-claim quotes are targeted separately later.", + criteria: { + ...threadChoices(selected), + new: "An unanswered planning question is raised and no current thread represents it.", + none: "No distinct planning question or clear current-thread target is raised.", + }, + }; + return { + state: fitState({ + current: { id: message.id, author: message.author, text: message.text.slice(0, 4000) }, + candidates: candidates.map(candidate => ({ + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + })), + recent: recent.filter((entry) => entry.id !== message.id).slice(-12).map((entry) => ({ + id: entry.id, + author: entry.author, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events), + }), + questions, + }; +} diff --git a/apps/server/src/conversation-plan/questions-duplicates.test.ts b/apps/server/src/conversation-plan/questions-duplicates.test.ts new file mode 100644 index 00000000..abe05e6f --- /dev/null +++ b/apps/server/src/conversation-plan/questions-duplicates.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { + cacheMessage, + cacheQuotes, + cacheText, + emptyCacheThread, +} from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("a sibling span and preceding text are not prior duplicate evidence", () => { + expect(cacheQuotes).toEqual([ + { quote: "🧪 Use Redis for API caching;", start: 0, end: 29 }, + { quote: "use Valkey for background jobs.", start: 30, end: 61 }, + ]); + let request = buildCandidateTargetingRequest( + cacheMessage, + [cacheMessage], + [emptyCacheThread], + cacheQuotes, + 1, + ); + let state = request.state as { + current: { text: string }; + preceding: string; + candidates: typeof cacheQuotes; + priorContributions: unknown[]; + }; + expect(request.questions.c1_duplicate).toBeUndefined(); + expect(state.priorContributions).toEqual([]); + expect(state.current.text).toBe(cacheQuotes[1]!.quote); + expect(state.preceding).toBe(cacheText.slice(0, cacheQuotes[1]!.start)); + expect(state.candidates).toEqual(cacheQuotes); + expect(request.questions.c1_role?.type).toBe("choice"); + expect(request.questions.c1_role?.instructions).toContain("reported agreement"); + expect(request.questions.c1_thread?.type).toBe("choice"); + expect(request.questions.c1_explicit_resolution?.type).toBe("noul"); + expect( + buildTriageRequest(cacheMessage, [], [emptyCacheThread], cacheQuotes).questions + .c1_owned_unretracted?.type, + ).toBe("noul"); +}); + +test("an accepted earlier message supplies a named prior-only duplicate comparator", () => { + let earlier: Chat.Entry = { + id: "cache-earlier-proposal", + text: cacheQuotes[0]!.quote, + author: { kind: "member", handle: "Mina" }, + ts: 2_000, + }; + let accepted = { + ...emptyCacheThread, + contributions: [{ + id: "cache-earlier-contribution", + kind: "option" as const, + text: earlier.text, + authoring: "quoted" as const, + sources: [{ + messageId: earlier.id, + author: { kind: "member" as const, handle: "Mina" }, + quote: earlier.text, + start: 0, + end: earlier.text.length, + role: "option" as const, + }], + actor: { kind: "classifier" as const }, + }], + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [earlier, cacheMessage], + [accepted], + cacheQuotes, + 0, + ); + let prior = (request.state as { + priorContributions: Array<{ id: string; text: string }>; + }).priorContributions; + expect(request.questions.c0_duplicate?.type).toBe("noul"); + expect(prior).toEqual([expect.objectContaining({ + id: "cache-earlier-contribution", + text: earlier.text, + })]); + expect(prior.every(item => item.text.length <= 300)).toBe(true); + expect(prior.some(item => item.id === cacheMessage.id)).toBe(false); +}); + +test("retrying an older message excludes later accepted contributions", () => { + let later: Chat.Entry = { + ...cacheMessage, + id: "later-cache-proposal", + text: cacheQuotes[0]!.quote, + ts: cacheMessage.ts + 1, + }; + let thread = { + ...emptyCacheThread, + contributions: [{ + id: "later-cache-contribution", + kind: "option" as const, + text: later.text, + authoring: "quoted" as const, + sources: [{ + messageId: later.id, + author: { kind: "member" as const, handle: "Ari" }, + quote: later.text, + start: 0, + end: later.text.length, + role: "option" as const, + }], + actor: { kind: "classifier" as const }, + }], + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [later], + [thread], + cacheQuotes, + 0, + ); + expect((request.state as { priorContributions: unknown[] }).priorContributions).toEqual([]); + expect(request.questions.c0_duplicate).toBeUndefined(); +}); + +test("reported earlier context without an accepted contribution is not duplicate evidence", () => { + let reported: Chat.Entry = { + ...cacheMessage, + id: "reported-cache-proposal", + text: `Mina said “${cacheQuotes[0]!.quote}” yesterday, but I disagree.`, + ts: cacheMessage.ts - 1, + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [reported], + [emptyCacheThread], + cacheQuotes, + 0, + ); + expect((request.state as { priorContributions: unknown[] }).priorContributions).toEqual([]); + expect(request.questions.c0_duplicate).toBeUndefined(); +}); + +test("a source-free persisted contribution cannot prove it predates a retry", () => { + let thread: ConversationPlan.Thread = { + ...emptyCacheThread, + contributions: [{ + id: "cache-redis-option", + kind: "option", + text: cacheQuotes[0]!.quote, + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + }; + let request = buildCandidateTargetingRequest(cacheMessage, [], [thread], cacheQuotes, 0); + let prior = (request.state as { + priorContributions: Array<{ id: string; text: string }>; + }).priorContributions; + expect(request.questions.c0_duplicate).toBeUndefined(); + expect(prior).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/questions-targeting.test.ts b/apps/server/src/conversation-plan/questions-targeting.test.ts new file mode 100644 index 00000000..5d3daec7 --- /dev/null +++ b/apps/server/src/conversation-plan/questions-targeting.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { buildCandidateTargetingRequest } from "./questions"; +import { fourCandidates, threadWithStatus } from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("four-span targeting keeps prompts for reopening a discarded thread", () => { + let { current, candidates } = fourCandidates(); + let request = buildCandidateTargetingRequest( + current, + [], + [threadWithStatus("discarded")], + candidates, + 3, + ); + + expect(request.questions.c3_raises_again?.type).toBe("noul"); + expect(request.questions.c3_reopening?.type).toBe("noul"); + expect(request.questions.c3_material_objection?.type).toBe("noul"); +}); + +test("four-span targeting keeps prompts for reopening a decided thread", () => { + let { current, candidates } = fourCandidates(); + let request = buildCandidateTargetingRequest( + current, + [], + [threadWithStatus("decided")], + candidates, + 3, + ); + + expect(request.questions.c3_reopening?.type).toBe("noul"); + expect(request.questions.c3_material_objection?.type).toBe("noul"); +}); + +test("four-span pending-settle targeting includes relation and its qualifier within 45 answers", () => { + let { current, candidates } = fourCandidates(); + let pendingThread = { + ...threadWithStatus("exploring"), + contributions: [{ + id: "oauth", + kind: "option" as const, + text: "GitHub OAuth", + authoring: "quoted" as const, + sources: [], + actor: { kind: "classifier" as const }, + }], + pendingSettle: { optionId: "oauth", proposer: "Mina", messageId: "proposal" }, + }; + let requests = candidates.map((_, index) => + buildCandidateTargetingRequest(current, [], [pendingThread], candidates, index) + ); + let persistedAnswerCount = requests.reduce( + (count, request) => count + Object.keys(request.questions).length, + 0, + ); + + expect(requests[2]!.questions.c2_relation?.type).toBe("choice"); + expect(requests[2]!.questions.c2_qualifies_pending_settle?.type).toBe("noul"); + expect( + requests.flatMap(request => Object.keys(request.questions)).filter(key => + key.endsWith("_duplicate") + ), + ).toEqual([]); + expect(persistedAnswerCount).toBe(44); +}); diff --git a/apps/server/src/conversation-plan/questions.test.ts b/apps/server/src/conversation-plan/questions.test.ts new file mode 100644 index 00000000..720dd819 --- /dev/null +++ b/apps/server/src/conversation-plan/questions.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { extractQuotes } from "./quotes"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { message, quotes, text } from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("act criteria separate an unresolved choice invitation from a proposed answer", () => { + let invitation = { + ...message, + id: "D19-m1", + text: + "Before the repository pilot, we need to choose how people sign in and how the hosted agent gets repository credentials.", + }; + let request = buildTriageRequest(invitation, [], [], extractQuotes(invitation.text)); + let act = request.questions.act; + if (act?.type !== "choice") throw new Error("missing act choice"); + expect((request.state as { candidates: unknown[] }).candidates).toHaveLength(2); + expect(act.criteria.question).toMatch(/need to (?:choose|decide) how/i); + expect(act.criteria.question).toMatch(/declarative|without a question mark/i); + expect(act.criteria.proposal).toMatch(/(?:concrete|specific).*(?:answer|plan|option)/i); + expect(act.criteria.proposal).toMatch( + /(?:not|without).*(?:need to choose|choice is needed|unresolved choice)/i, + ); +}); + +test("D19 m2 extracts four exact option quotes in order", () => { + expect(quotes.map(({ quote, start, end }) => ({ quote, start, end }))).toEqual([ + { quote: "GitHub OAuth", start: 35, end: 47 }, + { quote: "email magic links", start: 51, end: 68 }, + { quote: "each user’s GitHub token", start: 143, end: 167 }, + { quote: "GitHub App installation token", start: 177, end: 206 }, + ]); +}); + +test("triage includes all four D19 m2 options in its bounded candidate context", () => { + let triage = buildTriageRequest(message, [], [], quotes); + expect((triage.state as { candidates: unknown[] }).candidates).toHaveLength(4); + expect(triage.questions.c3_owned_unretracted?.type).toBe("noul"); +}); + +test("targeting accepts the fourth D19 m2 option candidate", () => { + let fourth = buildCandidateTargetingRequest(message, [], [], quotes, 3); + expect(fourth.questions.c3_role?.type).toBe("choice"); + expect((fourth.state as { current: { text: string } }).current.text).toBe(quotes[3]!.quote); + expect((fourth.state as { candidates: unknown[] }).candidates).toHaveLength(4); +}); + +test("candidate builders reject input beyond the four-quote budget instead of truncating", () => { + let remainderStart = quotes[3]!.end; + let overBudget = [ + ...quotes, + { + quote: text.slice(remainderStart), + start: remainderStart, + end: text.length, + }, + ]; + + expect(() => buildTriageRequest(message, [], [], overBudget)).toThrow(); + expect(() => buildCandidateTargetingRequest(message, [], [], overBudget, 4)).toThrow(); +}); diff --git a/apps/server/src/conversation-plan/questions.ts b/apps/server/src/conversation-plan/questions.ts new file mode 100644 index 00000000..bc37569d --- /dev/null +++ b/apps/server/src/conversation-plan/questions.ts @@ -0,0 +1,24 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import type { LinkedCardOptions } from "./question-shared"; +import { buildTargetingRequest } from "./question-targeting"; + +export { buildResearchOfferRequest } from "./question-research"; +export { hasWithdrawalCue, QUESTION_SET_VERSION } from "./question-shared"; +export type { LinkedCardOptions } from "./question-shared"; +export { buildTargetingRequest } from "./question-targeting"; +export { buildTriageRequest, triageQuestions } from "./question-triage"; + +/** Keep one candidate's target questions free of later clauses in the same message. */ +export function buildCandidateTargetingRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + index: number, + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +): JevRequest { + return buildTargetingRequest(message, recent, threads, candidates, index, events, linkedCards); +} diff --git a/apps/server/src/conversation-plan/quote-budget.ts b/apps/server/src/conversation-plan/quote-budget.ts new file mode 100644 index 00000000..cd13f9e2 --- /dev/null +++ b/apps/server/src/conversation-plan/quote-budget.ts @@ -0,0 +1,7 @@ +export const MAX_QUOTE_CANDIDATES = 4; + +export function assertQuoteBudget(candidates: readonly unknown[]): void { + if (candidates.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } +} diff --git a/apps/server/src/conversation-plan/quotes.test-fixtures.ts b/apps/server/src/conversation-plan/quotes.test-fixtures.ts new file mode 100644 index 00000000..c3c768e3 --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.test-fixtures.ts @@ -0,0 +1,21 @@ +/** Exact development chat excerpts from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. */ +let messages: Record = { + // Source: evals/conversation-shape/fixtures/development/D19.json, input.steps id m1, kind say. + "D19/m1": + "Before the repository pilot, we need to choose how people sign in and how the hosted agent gets repository credentials.", + // Source: evals/conversation-shape/fixtures/development/D19.json, input.steps id m2, kind say. + "D19/m2": + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent's repository credentials, we could pass each user's GitHub token or use a GitHub App installation token. Those are two different calls.", + // Source: evals/conversation-shape/fixtures/development/D02.json, input.steps id m2, kind say. + "D02/m2": "a small VPS is enough for this traffic. we'll have to patch it ourselves.", + // Source: evals/conversation-shape/fixtures/development/D03.json, input.steps id m1, kind say. + "D03/m1": "what sends transactional notifications? our SMTP relay, Postmark, or SES?", + // Source: evals/conversation-shape/fixtures/development/D04.json, input.steps id m5, kind say. + "D04/m5": "worth comparing. R2's egress could matter for thumbnails.", +}; + +export async function fixtureMessage(caseId: string, stepId: string): Promise { + let text = messages[`${caseId}/${stepId}`]; + if (text === undefined) throw new Error(`${caseId}/${stepId} is not a message`); + return text; +} diff --git a/apps/server/src/conversation-plan/quotes.test.ts b/apps/server/src/conversation-plan/quotes.test.ts new file mode 100644 index 00000000..8d7d5f0e --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { fixtureMessage } from "./quotes.test-fixtures"; +import { extractQuotes } from "./quotes"; + +test("D19 m1 yields two exact independent question spans", async () => { + let m1 = await fixtureMessage("D19", "m1"); + let m1Quotes = extractQuotes(m1); + + /* + * Before the fix m1 was one span [0,119). The fallback only splits sentence + * punctuation and a small imperative-oriented "and" boundary, so it misses + * the two independent question topics joined by "and how". + */ + expect(m1Quotes).toEqual([ + { quote: "how people sign in", start: 47, end: 65 }, + { quote: "how the hosted agent gets repository credentials", start: 70, end: 118 }, + ]); + for (let quote of m1Quotes) { + expect(m1.slice(quote.start, quote.end)).toBe(quote.quote); + } +}); + +test("D19 m2 yields four exact atomic alternatives instead of bundled clauses", async () => { + let m2 = await fixtureMessage("D19", "m2"); + let m2Quotes = extractQuotes(m2); + /* + * Before the fix m2 was [0,69), [70,207), and [208,238): both "or" lists + * stayed bundled and the closing explanation became an unrelated candidate. + */ + expect(m2Quotes).toEqual([ + { quote: "GitHub OAuth", start: 35, end: 47 }, + { quote: "email magic links", start: 51, end: 68 }, + { quote: "each user's GitHub token", start: 143, end: 167 }, + { quote: "GitHub App installation token", start: 177, end: 206 }, + ]); + for (let quote of m2Quotes) { + expect(m2.slice(quote.start, quote.end)).toBe(quote.quote); + expect(quote.quote).not.toMatch(/\bor\b/i); + } +}); + +test("keeps current D02-D04 exact extraction controls", async () => { + let d02 = await fixtureMessage("D02", "m2"); + let d03 = await fixtureMessage("D03", "m1"); + let d04 = await fixtureMessage("D04", "m5"); + expect(extractQuotes(d02)).toEqual([ + { quote: "a small VPS is enough for this traffic.", start: 0, end: 39 }, + { quote: "we'll have to patch it ourselves.", start: 40, end: 73 }, + ]); + expect(extractQuotes(d03)).toEqual([ + { quote: "SMTP relay", start: 44, end: 54 }, + { quote: "Postmark", start: 56, end: 64 }, + { quote: "SES", start: 69, end: 72 }, + ]); + expect(extractQuotes(d04)).toEqual([ + { quote: "worth comparing.", start: 0, end: 16 }, + { quote: "R2's egress could matter for thumbnails.", start: 17, end: 57 }, + ]); +}); + +test("does not split a compound option name or quoted/rejected alternatives", () => { + let compound = + "We could use the Selection and Range APIs, CodeMirror, or our own document model."; + let quoted = + 'The note says, "Use GitHub OAuth or email magic links," but that option was rejected.'; + let rejected = + "We could use GitHub OAuth or email magic links, but the team rejected both options."; + for (let text of [compound, quoted, rejected]) { + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); + } +}); + +test("quote extraction accepts four candidates and rejects a fifth without truncation", () => { + expect(extractQuotes("First. Second. Third. Fourth.")).toEqual([ + { quote: "First.", start: 0, end: 6 }, + { quote: "Second.", start: 7, end: 14 }, + { quote: "Third.", start: 15, end: 21 }, + { quote: "Fourth.", start: 22, end: 29 }, + ]); + expect(() => extractQuotes("First. Second. Third. Fourth. Fifth.")) + .toThrow("source quote count exceeds 4"); +}); + +test.each( + [ + [ + "Which queue fits our launch? Redis, SQS, Postgres, or a small in-process queue with disk replay.", + ["Redis", "SQS", "Postgres", "a small in-process queue with disk replay"], + ], + [ + "Cedar, Elm, Ash, or browser Selection and Range with a custom model.", + ["Cedar", "Elm", "Ash", "browser Selection and Range with a custom model"], + ], + ["Redis, SQS, or Postgres.", ["Redis", "SQS", "Postgres"]], + ] as const, +)("extracts bounded alternatives without domain vocabulary: %s", (text, labels) => { + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([...labels]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("a fifth explicit alternative fails the budget rather than becoming one sentence", () => { + expect(() => extractQuotes("Cedar, Elm, Ash, Pine, or Oak.")).toThrow( + "source quote count exceeds 4", + ); +}); diff --git a/apps/server/src/conversation-plan/quotes.ts b/apps/server/src/conversation-plan/quotes.ts new file mode 100644 index 00000000..d7ac224f --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.ts @@ -0,0 +1,337 @@ +import { assertQuoteBudget, MAX_QUOTE_CANDIDATES } from "./quote-budget"; + +/** Exact UTF-16 spans from the saved message. This path never invents wording. */ +export type QuoteCandidate = { quote: string; start: number; end: number }; + +const DIRECT_DECISION_CLAUSE = + /^(?:(?:(?:for|before|after|during|in|on|regarding|about)\b|as part of\b)[^.!?,]{1,100},\s*)?(?:we|i)\s+(?:need to|have to|should|must)\s+(?:decide|choose)\s*$/i; + +/** Exact two-question structure, before judging whether the request is attributed. */ +function compoundDecisionPrefix( + text: string, + candidates: readonly QuoteCandidate[], +): { prefix: string; context: string } | undefined { + if (text.length > 500 || candidates.length !== 2) return; + let [first, second] = candidates; + if (!first || !second) return; + if ( + ![first.start, first.end, second.start, second.end].every(Number.isSafeInteger) + || first.start < 0 || first.end >= second.start || second.end > text.length + ) return; + if ( + text.slice(first.start, first.end) !== first.quote + || text.slice(second.start, second.end) !== second.quote + ) return; + if (first.quote.toLowerCase() === second.quote.toLowerCase()) return; + let prefix = text.slice(0, first.start); + let between = text.slice(first.end, second.start); + let suffix = text.slice(second.end); + if ( + !/\b(?:we|i)\s+(?:need to|have to|should|must)\s+(?:decide|choose)\s*$/i + .test(prefix) + || !/^\s*,?\s*and\s+$/i.test(between) + || !/^[.!?]?\s*$/.test(suffix) + || !candidates.every(candidate => /^(?:how|what|which)\b[\s\S]{3,160}$/i.test(candidate.quote)) + ) return; + let sentenceStart = + Math.max(prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?")) + 1; + let directPrefix = prefix.slice(sentenceStart); + return { prefix: directPrefix, context: directPrefix + between + suffix }; +} + +/** A compound request lacking a direct decision clause from this speaker. */ +export function isAttributedCompoundDecision( + text: string, + candidates: readonly QuoteCandidate[], +): boolean { + let decision = compoundDecisionPrefix(text, candidates); + return decision !== undefined && !DIRECT_DECISION_CLAUSE.test(decision.prefix.trim()); +} + +/** Two exact question spans introduced by one direct, speaker-owned decision request. */ +export function isExplicitCompoundDecision( + text: string, + candidates: readonly QuoteCandidate[], +): boolean { + let decision = compoundDecisionPrefix(text, candidates); + return decision !== undefined && DIRECT_DECISION_CLAUSE.test(decision.prefix.trim()) + && !/\b(?:according to|if|unless|maybe|perhaps|could|would)\b/i.test(decision.context) + && !/["“”‘’🙄]/u.test(decision.context); +} + +const CLAUSE_BOUNDARY = + /\s+and\s+(?=(?:remember|keep|show|ask|make|use|choose|save|reopen|compare|require|add|remove|start|offer|set|let|store|preview)\b)/gi; + +const OPTION_CLAUSE_INTRODUCER = + /\b(?:but|however|yet|because|since|as|although|though|whereas|while|if|unless)\b/i; + +/** Closed-class clause heads cannot begin an atomic provider label. */ +function startsWithClauseHead(quote: string): boolean { + return /^(?:as|because|if|unless|when|while|since|although|though)\b/i.test(quote.trim()); +} + +/** Parse only an explicit bounded list; each returned label is a source slice. */ +function listedAlternatives( + text: string, +): { kind: "question" | "bare"; quotes: QuoteCandidate[] } | undefined { + if (text.length > 500 || /["“”‘’🙄]/u.test(text)) return; + let trimmed = text.trim(); + let question = /^(?:what|which|how|where|should)\b[^.!?;]{3,160}\?\s+/i.exec(trimmed); + let kind: "question" | "bare" = question ? "question" : "bare"; + if ( + question + && /\b(?:if|unless|when|according|reported|said|asked|not|never)\b|\bas\s+(?:a|an|the)\b/i + .test(question[0]) + ) return; + if (!question && /^(?:not|never|we|i|they|he|she|the|according)\b/i.test(trimmed)) return; + let bodyStart = text.indexOf(trimmed) + (question?.[0].length ?? 0); + if (question && /^our\s+/i.test(text.slice(bodyStart))) { + bodyStart += /^our\s+/i.exec(text.slice(bodyStart))![0].length; + } + let bodyEnd = text.indexOf(trimmed) + trimmed.length; + let ending = text[bodyEnd - 1]; + if (question ? ending !== "?" && ending !== "." : ending !== ".") return; + bodyEnd--; + let body = text.slice(bodyStart, bodyEnd); + if (!/,\s+or\s+/i.test(body) || /[;!?]|\s\/\s/u.test(body)) return; + let labels = body.split(/,\s*/); + if (labels.length < 3 || !/^or\s+/i.test(labels.at(-1) ?? "")) return; + labels[labels.length - 1] = labels.at(-1)!.replace(/^or\s+/i, ""); + if (labels.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } + if ( + labels.some(label => + label.length < 2 || label.length > 80 || label.split(/\s+/).length > 12 + || !/^[\p{L}\p{N}][\p{L}\p{N}\s+&/.#'-]*$/u.test(label) + || /\b(?:or|not|never|because|since|if|unless|when|while|reported|said|asked|rejected|is|are)\b/i + .test(label) + || startsWithClauseHead(label) + ) + ) return; + if (new Set(labels.map(label => label.toLowerCase())).size !== labels.length) return; + let quotes: QuoteCandidate[] = []; + let searchFrom = bodyStart; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0 || start >= bodyEnd) return; + let end = start + label.length; + quotes.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return { kind, quotes }; +} + +export function explicitListQuotes(text: string): QuoteCandidate[] { + return listedAlternatives(text)?.quotes ?? []; +} + +/** Alternatives from a direct question, excluding reported or negated choices. */ +export function directAlternativeQuotes(text: string): QuoteCandidate[] { + let trimmed = text.trim(); + if (/\b(?:not|never|don't|shouldn['’]t|avoid)\b|🙄/iu.test(trimmed)) return []; + let listed = listedAlternatives(text); + if (listed?.kind === "question") return listed.quotes; + if (/[.!;]/.test(trimmed.slice(0, -1))) return []; + if ( + /^Which\b/i.test(trimmed) + && /\b(?:did|does|do|said|say|asked|reported|according)\b/i.test( + trimmed.slice(0, trimmed.indexOf(":")), + ) + ) return []; + let match = trimmed.match( + /^Should we\s+(use\s+[^,;?]+),\s*([^,;?]+),\s*or\s+([^,;?]+)\?$/i, + ) ?? trimmed.match( + /^Should we\b[^?]{1,160}\b(?:in|on|at|via|with)\s+([^,;?]+?)\s+or\s+([^,;?]+)\?$/i, + ) ?? trimmed.match( + /^Which\s+(?:[\w-]+\s+){1,4}should\s+[^?:]{1,120}:\s*([^,;?]+?)\s+or\s+([^,;?]+)\?$/i, + ) + ?? trimmed.match(/^Should\b[^?]{1,160}\b(?:use|go\s+in)\s+([^,;?]+?)\s+or\s+([^,;?]+)\?$/i); + if (!match) return []; + let result: QuoteCandidate[] = []; + let offset = text.indexOf(trimmed); + let searchFrom = 0; + for (let raw of match.slice(1)) { + if (/\bor\b/i.test(raw)) return []; + let start = trimmed.indexOf(raw, searchFrom); + if (start < 0) return []; + let end = start + raw.length; + while (/\s/.test(trimmed[start] ?? "")) start++; + while (/\s/.test(trimmed[end - 1] ?? "")) end--; + let opening = trimmed[start]; + let closing = trimmed[end - 1]; + if ((opening === '"' || closing === '"') && (opening !== '"' || closing !== '"')) { + return []; + } + if ((opening === "“" || closing === "”") && (opening !== "“" || closing !== "”")) { + return []; + } + if ((opening === '"' && closing === '"') || (opening === "“" && closing === "”")) { + start++; + end--; + } + if (start >= end) return []; + result.push({ + quote: text.slice(offset + start, offset + end), + start: offset + start, + end: offset + end, + }); + searchFrom = end; + } + return result; +} + +/** A bounded, first-person explanation of two alternatives for a named topic. */ +export function declarativeAlternativeQuotes(text: string): QuoteCandidate[] { + if (text.length > 500) return []; + let match = text.trim().match( + /^By\s+[\w-]+(?:\s+[\w-]+){0,8}\s+I mean\s+([^,;.!?]{2,100}?)\s+or\s+([^,;.!?]{2,100})\.$/i, + ); + if (!match) return []; + let labels = match.slice(1).map(label => label.trim()); + if ( + labels.some(label => + /\b(?:or|not|never|don't|didn't|wouldn't|maybe|perhaps|actually)\b|['"“”‘’]/i.test(label) + ) || labels[0]!.toLowerCase() === labels[1]!.toLowerCase() + ) return []; + let result: QuoteCandidate[] = []; + let searchFrom = text.toLowerCase().indexOf("i mean") + "i mean".length; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0) return []; + let end = start + label.length; + result.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return result; +} + +/** Exact labels from a speaker-owned, explicit three-way choice. */ +export function multiAlternativeQuotes(text: string): QuoteCandidate[] { + if (text.length > 500) return []; + let prefix = text.match( + /^\s*(?:(?:We|I)\s+(?:could|can|might|should)\s+(?:do|use|choose|try|go with)|By\s+[\w-]+(?:\s+[\w-]+){0,8}\s+I mean)\s+/i, + ); + if (!prefix) return []; + let bodyStart = prefix[0].length; + let bodyEnd = text.trimEnd().length; + if (/[.!?]$/.test(text.slice(0, bodyEnd))) bodyEnd--; + let body = text.slice(bodyStart, bodyEnd); + if ( + /[.!?;:'"“”‘’]/u.test(body) + || /\b(?:not|never|don't|wouldn't|instead|none)\b/i.test(body) + || OPTION_CLAUSE_INTRODUCER.test(body) + ) { + return []; + } + let context = body.match(/\s+for\s+[\p{L}\p{N}\s/-]{2,100}$/u); + if (context) bodyEnd -= context[0].length; + let choices = text.slice(bodyStart, bodyEnd); + let separators = [...choices.matchAll(/\s+or\s+|,\s*(?:or\s+)?/gi)]; + if (separators.length !== 2 || !/\bor\b/i.test(separators[1]![0])) return []; + let spans: QuoteCandidate[] = []; + let start = bodyStart; + for (let separator of separators) { + let end = bodyStart + separator.index; + spans.push({ quote: text.slice(start, end), start, end }); + start = end + separator[0].length; + } + spans.push({ quote: text.slice(start, bodyEnd), start, end: bodyEnd }); + if ( + spans.some(({ quote }) => + quote.length < 2 || quote.length > 80 || quote.split(/\s+/).length > 6 + || !/^[\p{L}\p{N}][\p{L}\p{N}\s+&/.#-]*$/u.test(quote) + || /\b(?:and|or|not|never|don't)\b/i.test(quote) + ) + ) return []; + if (new Set(spans.map(({ quote }) => quote.toLowerCase())).size !== 3) return []; + return spans; +} + +/** Explicitly scoped decisions can contain multiple independent atomic spans. */ +function scopedDecisionQuotes(text: string): QuoteCandidate[] { + if (text.length > 500 || /["“”]|\b(?:not|never|reject(?:ed)?|dismissed)\b/i.test(text)) { + return []; + } + let question = text.match( + /^.{0,100}\bwe need to (?:choose|decide)\s+(how [^.!?;]{3,100}?)\s+and\s+(how [^.!?;]{3,100})\.\s*$/i, + ); + let alternatives = text.match( + /^\s*For [^.!?;]{3,100}, the options are ([^.!?;]{2,80}?) or ([^.!?;]{2,80})\.\s+Separately, for [^.!?;]{3,100}, we could (?:pass|use) ([^.!?;]{2,100}?) or (?:use|pass) (?:a |an |the )?([^.!?;]{2,100})\.(?:\s+[^.!?;]{1,80}\.)?\s*$/i, + ); + let labels = question?.slice(1) ?? alternatives?.slice(1); + if ( + !labels + || labels.some(label => + /\bor\b|\b(?:not|never|reject(?:ed)?|dismissed)\b|["“”]/i.test(label) + || alternatives !== null && label.split(/\s+/).length > 6 + ) + ) return []; + let spans: QuoteCandidate[] = []; + let searchFrom = 0; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0) return []; + let end = start + label.length; + spans.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return spans; +} + +export function extractQuotes(text: string): QuoteCandidate[] { + let scoped = scopedDecisionQuotes(text); + if (scoped.length) return scoped; + let listed = listedAlternatives(text); + if (listed) return listed.quotes; + let direct = directAlternativeQuotes(text); + if (direct.length === 3) return direct; + let multi = multiAlternativeQuotes(text); + if (multi.length) return multi; + let declarative = declarativeAlternativeQuotes(text); + if (declarative.length) return declarative; + let candidates: QuoteCandidate[] = []; + let append = (start: number, end: number): void => { + let raw = text.slice(start, end); + let left = raw.length - raw.trimStart().length; + let right = raw.trimEnd().length; + start += left; + end = start + right - left; + if (end > start && end - start <= 2048) { + candidates.push({ quote: text.slice(start, end), start, end }); + assertQuoteBudget(candidates); + } + }; + let segment = (start: number, end: number): void => { + let content = text.slice(start, end); + let alternatives = directAlternativeQuotes(content); + if (alternatives.length) { + if (candidates.length + alternatives.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } + for (let alternative of alternatives) { + append(start + alternative.start, start + alternative.end); + } + return; + } + let begin = start; + for (let match of content.matchAll(CLAUSE_BOUNDARY)) { + let split = start + match.index; + let left = text.slice(begin, split).trim(); + let shortAssent = /^(?:yes|yeah|yep|sure|okay|ok|agreed|i agree|sounds good)[,!]?$/i + .test(left); + if (left.split(/\s+/).length < 3 && !shortAssent) continue; + append(begin, split); + begin = split + match[0].length; + } + append(begin, end); + }; + let begin = 0; + let boundaries = /[;.!?](?=\s|$)/g; + for (let match of text.matchAll(boundaries)) { + segment(begin, match.index + match[0].length); + begin = match.index + match[0].length; + } + segment(begin, text.length); + return candidates; +} diff --git a/apps/server/src/conversation-plan/recent-thread-context.test.ts b/apps/server/src/conversation-plan/recent-thread-context.test.ts new file mode 100644 index 00000000..dfc63167 --- /dev/null +++ b/apps/server/src/conversation-plan/recent-thread-context.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import { + buildCandidateTargetingRequest, + buildResearchOfferRequest, + buildTargetingRequest, + buildTriageRequest, +} from "./questions"; +import { visibleThreads } from "./question-context"; +import { message } from "./policy-initial.test-fixtures"; +import { extractQuotes } from "./quotes"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import type { JevRequest } from "./jev"; + +function populated(count = 20): ConversationPlan.State { + let state = initialState(); + for (let index = 0; index < count; index++) { + let threadId = `thread-${index}`; + let quote = `Which provider for service ${index}?`; + let base = { + threadId, + origin: "classifier" as const, + actor: { kind: "classifier" } as const, + at: index + 1, + }; + let source = { + messageId: `message-${index}`, + author: { kind: "member", handle: "alice" } as const, + quote, + start: 0, + end: quote.length, + role: "question" as const, + }; + state = applyEvent(state, { + ...base, + id: `opened-${index}`, + type: "thread.opened", + observedThreadVersion: 0, + question: quote, + source, + }); + for (let option = 0; option < 2; option++) { + let text = `Provider ${index}-${option}`; + state = applyEvent(state, { + ...base, + id: `added-${index}-${option}`, + type: "option.added", + observedThreadVersion: option + 1, + contribution: { id: `option-${index}-${option}`, text, authoring: "quoted" }, + source: { ...source, quote: text, end: text.length, role: "option" }, + }); + } + } + return state; +} + +function ids(selected: ReturnType): string[] { + return selected.map(({ thread }) => thread.id); +} + +test("a twentieth thread enters the bounded context while small contexts retain their order", () => { + let state = populated(); + let expected = state.threads.slice(-12).map(thread => thread.id); + expect(ids(visibleThreads(state.threads))).toEqual(expected); + expect(ids(visibleThreads(state.threads, state.events))).toEqual(expected); + let small = state.threads.slice(0, 12); + expect(ids(visibleThreads(small, state.events))).toEqual(small.map(thread => thread.id)); +}); + +test("accepted corrections refresh old threads by event order across every request builder", () => { + let state = populated(); + state = applyEvent(state, { + id: "latest-correction", + type: "card.corrected", + threadId: "thread-0", + observedThreadVersion: 3, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 0, + change: { kind: "edit", field: "question", text: "Which current provider for service 0?" }, + }); + let expected = ["thread-0", ...state.threads.slice(-11).map(thread => thread.id)]; + let current = message("current", "We need current provider costs."); + let candidates = extractQuotes(current.text); + let requests = [ + buildTriageRequest(current, [], state.threads, candidates, state.events), + buildTargetingRequest(current, [], state.threads, candidates, undefined, state.events), + buildCandidateTargetingRequest(current, [], state.threads, candidates, 0, state.events), + buildResearchOfferRequest(current, [], state.threads, candidates, state.events), + ]; + for (let request of requests) { + let context = request.state as { threads: Array<{ id: string; options: unknown[] }> }; + expect(context.threads.map(thread => thread.id)).toEqual(expected); + expect(context.threads).toHaveLength(12); + expect(context.threads.flatMap(thread => thread.options).length).toBeLessThanOrEqual(32); + expect(JSON.stringify(request.state).length).toBeLessThanOrEqual(23_500); + } +}); + +test("a move refreshes both affected threads without promoting discarded threads over live ones", () => { + let state = populated(); + state = applyEvent(state, { + id: "move", + type: "card.corrected", + threadId: "thread-0", + observedThreadVersion: 3, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 21, + change: { + kind: "move", + contributionId: "option-0-0", + targetThreadId: "thread-1", + targetVersion: 3, + }, + }); + expect(ids(visibleThreads(state.threads, state.events))).toEqual([ + "thread-0", + "thread-1", + ...state.threads.slice(-10).map(thread => thread.id), + ]); + state = applyEvent(state, { + id: "discard", + type: "thread.discarded", + threadId: "thread-19", + observedThreadVersion: 3, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 22, + }); + expect(ids(visibleThreads(state.threads, state.events))).not.toContain("thread-19"); +}); + +test("production interpretation supplies the accepted recency to research and targeting", async () => { + let state = populated(); + state = applyEvent(state, { + id: "newest-touch", + type: "card.corrected", + threadId: "thread-0", + observedThreadVersion: 3, + origin: "human", + actor: { kind: "member", handle: "alice" }, + at: 0, + change: { kind: "edit", field: "question", text: "Which current provider for service 0?" }, + }); + let requests: JevRequest[] = []; + await interpretMessage({ + channelId: "channel", + message: message("current", "We need current provider costs."), + recent: [], + state, + ask: async request => { + requests.push(request); + return mockResult(request.questions, { research_need: 0.95, reason: 0.8 }); + }, + }); + expect(requests).toHaveLength(3); + for (let request of requests) { + let context = request.state as { threads: Array<{ id: string }> }; + expect(context.threads.map(thread => thread.id)).toEqual([ + "thread-0", + ...state.threads.slice(-11).map(thread => thread.id), + ]); + } +}); diff --git a/apps/server/src/conversation-plan/refined-option-policy.test.ts b/apps/server/src/conversation-plan/refined-option-policy.test.ts new file mode 100644 index 00000000..531c745c --- /dev/null +++ b/apps/server/src/conversation-plan/refined-option-policy.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import { planEvents } from "./policy"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; + +function refinedInput(role = "support") { + let input = stanceInput(role, role === "support" ? "I support Beta." : "I oppose Beta."); + let thread = input.state.threads[0]!; + input.state = applyEvent(input.state, { + id: "refined-option", + type: "option.relabeled", + threadId: thread.id, + observedThreadVersion: thread.version, + observedCardRevision: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1000, + optionId: "alpha", + label: "Beta", + }); + input.linkedCards!.get("provider")!.options[0]!.label = "Beta"; + return input; +} + +test.each(["support", "objection"])( + "%s names the refined option while retaining its original evidence", + role => { + let input = refinedInput(role); + let option = structuredClone(input.state.threads[0]!.contributions[0]!); + let result = planEvents(input); + expect(result.events).toMatchObject([{ + type: "stance.changed", + optionId: "alpha", + position: role === "support" ? "support" : "oppose", + source: { quote: input.message.text }, + }]); + expect(input.state.threads[0]!.contributions[0]).toEqual(option); + expect(option).toMatchObject({ text: "Alpha", displayLabel: "Beta" }); + expect(option.sources[0]!.quote).toBe("Alpha"); + }, +); + +test("interpretation context names the refined option and pending choice without changing evidence", () => { + let input = refinedInput(); + pendingChoice(input); + let original = structuredClone(input.state); + let triage = buildTriageRequest( + input.message, + [], + input.state.threads, + input.candidates, + input.state.events, + ); + let targeting = buildCandidateTargetingRequest( + input.message, + [], + input.state.threads, + input.candidates, + 0, + input.state.events, + input.linkedCards, + ); + for (let request of [triage, targeting]) { + let state = request.state as { + threads: Array<{ + options: Array<{ id: string; text: string }>; + pendingSettle?: { optionId: string; option: string }; + }>; + }; + expect(state.threads[0]!.options).toEqual([{ id: "alpha", text: "Beta" }]); + expect(state.threads[0]!.pendingSettle).toMatchObject({ + optionId: "alpha", + option: "Beta", + }); + } + expect(input.state).toEqual(original); +}); 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/research-offer-detection-card-focus.test.ts b/apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts new file mode 100644 index 00000000..ebebbf78 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; +import { + ada, + d03Options, + entry, + exactSource, + expectNoDurableOffer, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + resendId, + s3Id, + seededThread, + sesId, + sharedDiskId, + waitFor, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("rejects missing, partial, and ambiguous R2 focus names", async () => { + let message = entry("D04-invalid-r2-focus", "R2's egress could matter for thumbnails.", 11); + let base: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]), + entry("D04-b2-not-present", "B2's egress could matter for thumbnails.", 11), + { ...base, quote: "B2's egress could matter for thumbnails." }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]), + entry("D04-r2x-not-whole-token", "R2x's egress could matter for thumbnails.", 11), + { ...base, quote: "R2x's egress could matter for thumbnails." }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + { id: resendId, text: "Wasabi R2" }, + ]), + message, + { ...base, optionIds: [s3Id, sharedDiskId, r2Id, resendId] }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: resendId, text: "Amazon S3 / Cloudflare R2" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Wasabi" }, + ]), + entry( + "D04-compound-option-focus", + "Amazon S3 / Cloudflare R2's egress could matter for thumbnails.", + 11, + ), + { + ...base, + optionIds: [resendId, sharedDiskId, r2Id], + quote: "Amazon S3 / Cloudflare R2's egress could matter for thumbnails.", + scopeChoice: resendId, + }, + ); + }); + + test("revalidates a research-only offer against the linked card at commit", async () => { + let state = d03Options(); + let message = entry( + "D03-card-changed-during-interpretation", + "I don't know current provider prices.", + 11, + ); + let ready!: () => void; + let release!: () => void; + let interpretationStarted = new Promise(resolve => ready = resolve); + let allowCommit = new Promise(resolve => release = resolve); + let setup = harness(state, async () => { + ready(); + await allowCommit; + return { + events: [], + researchOffer: { + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + }; + }); + let originalCard = { + threadId: "mail", + status: "open", + definition: { + questions: [{ + options: [ + { id: relayId, label: "SMTP relay" }, + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + ], + }], + }, + history: [], + }; + setup.plan.records.set("card-mail", originalCard as never); + + await setup.processor.accept(message); + setup.processor.afterMessage(); + await interpretationStarted; + setup.plan.records.set("card-mail", { + ...originalCard, + definition: { + questions: [{ + options: [ + ...originalCard.definition.questions[0]!.options, + { id: resendId, label: "Resend" }, + ], + }], + }, + } as never); + release(); + await waitFor(() => setup.durable.state.analysis.some(item => item.messageId === message.id)); + + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.threads.find(item => item.id === "mail")?.contributions) + .toHaveLength(3); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)).toMatchObject({ + messageId: message.id, + status: "unlinked", + }); + expect(setup.errors).toEqual([]); + setup.processor.stop(); + }); + + test("does not offer from a card whose option snapshot already differs from its thread", async () => { + let state = d03Options(); + let message = entry( + "D03-card-already-has-fourth-option", + "I don't know current provider prices.", + 11, + ); + let setup = harness(state, async input => { + expect(input.linkedCards?.get("mail")?.options).toHaveLength(4); + return { + events: [], + researchOffer: { + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + }; + }); + setup.plan.records.set("card-mail", { + threadId: "mail", + status: "open", + definition: { + questions: [{ + options: [ + { id: relayId, label: "SMTP relay" }, + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + { id: resendId, label: "Resend" }, + ], + }], + }, + history: [], + } as never); + + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)).toMatchObject({ + messageId: message.id, + status: "unlinked", + }); + expect(setup.errors).toEqual([]); + setup.processor.stop(); + }); + + test("dismissed D03 current-price uncertainty suppresses a generic same-context paraphrase", async () => { + let state = d03Options(); + let first = entry("D03-m4-dismiss", "I don't know current provider prices.", 11); + let second = entry("D03-m7-paraphrase", "We still need current provider pricing.", 12); + let setup = harness( + state, + inputInterpreter(message => ({ + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + })), + ); + let starts = 0; + await processMessage(setup, first); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer?.status).toBe("offered"); + let dismissed = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "dismiss", actionId: "D03-dismiss" }, + ada, + "ada-principal", + async () => { + starts++; + return { execution: "started", researchRequestId: "should-not-start" }; + }, + ); + expect(dismissed.execution).toBe("none"); + await processMessage(setup, second); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.researchOffers?.[0]?.status).toBe("dismissed"); + expect(starts).toBe(0); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts b/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts new file mode 100644 index 00000000..220ed974 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import type { Interpretation } from "./interpret"; +import type { JevAnswer } from "./jev"; +import { + ada, + d03Options, + detectResearchOffer, + entry, + exactSource, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + resendId, + s3Id, + seededThread, + sesId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("a replayed D03 generic concern does not publish a duplicate offer", async () => { + let state = d03Options(); + let first = entry("D03-replay-first", "I don't know current provider prices.", 11); + let replay = entry("D03-replay-again", "I don't know current provider prices.", 12); + let setup = harness( + state, + inputInterpreter(() => ({ + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: first.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + })), + ); + await processMessage(setup, first); + await processMessage(setup, replay); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.analysis.some(item => item.messageId === first.id)).toBe(true); + expect(setup.durable.state.analysis.some(item => item.messageId === replay.id)).toBe(true); + expect(setup.durable.entries.filter(item => item.id === replay.id)).toHaveLength(1); + setup.processor.stop(); + }); + + test("rejects a stale label at admission but accepts a fresh quote naming the current label", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); + state = applyEvent(state, { + id: "rename-r2", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Backblaze B2", + observedCardRevision: 0, + }); + let staleMessage = entry( + "storage-old-label", + "Compare Amazon S3 and Cloudflare R2 costs.", + 11, + ); + let staleJudgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: staleMessage.text, + }; + let stale = await detectResearchOffer(state, staleMessage, staleJudgment); + expect(stale.researchOffer).toBeDefined(); + let staleSetup = harness(state, inputInterpreter(() => staleJudgment)); + await processMessage(staleSetup, staleMessage); + expect(staleSetup.durable.state.researchOffers ?? []).toEqual([]); + expect(staleSetup.durable.state.analysis.some(item => item.messageId === staleMessage.id)) + .toBe(true); + staleSetup.processor.stop(); + + let currentMessage = entry( + "storage-current-label", + "Compare Amazon S3 and Backblaze B2 costs.", + 12, + ); + let currentJudgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: currentMessage.text, + }; + let current = await detectResearchOffer(state, currentMessage, currentJudgment); + expect(current.researchOffer).toMatchObject({ + threadId: "storage", + optionIds: [s3Id, r2Id], + source: exactSource(currentMessage, currentMessage.text), + }); + + let setup = harness(state, inputInterpreter(() => currentJudgment)); + await processMessage(setup, currentMessage); + expect(setup.durable.state.researchOffers?.[0]).toMatchObject({ + task: { + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: r2Id, labelAtOffer: "Backblaze B2" }, + ], + }, + brief: + `Compare current costs for Amazon S3 and Backblaze B2. Discussion context: “${currentMessage.text}”`, + }); + setup.processor.stop(); + }); + + test("does not pair a selected option with a different explicitly named option", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + { id: resendId, text: "Redis" }, + ]); + let message = entry("wrong-pair", "Compare Amazon S3 and Redis costs.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: message.text, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toBeUndefined(); + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)).toBe(true); + setup.processor.stop(); + }); + + for ( + let scenario of [ + { + name: "does not turn a named outside alternative into an S3/R2 comparison", + id: "outside-option", + text: "Compare Amazon S3 and Redis costs.", + ts: 11, + }, + { + name: "does not pair S3/R2 when a third provider is also named", + id: "multiple-named-providers", + text: "Compare Amazon S3 and Cloudflare R2 and Redis costs.", + ts: 11, + }, + { + name: "rejects an outside operand in a short explicit alternative", + id: "outside-or", + text: "Amazon S3 or Redis costs?", + ts: 11, + }, + { + name: "rejects an outside operand in a how-do-they-compare question", + id: "outside-how-compare", + text: "How do Amazon S3 and Redis costs compare?", + ts: 12, + }, + { + name: "rejects an outside option in a comparative-cost statement", + id: "outside-than", + text: "Amazon S3 is cheaper than Redis at current prices.", + ts: 13, + }, + ] + ) { + test(scenario.name, async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); + let message = entry(scenario.id, scenario.text, scenario.ts); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: message.text, + }; + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)) + .toBe(true); + setup.processor.stop(); + }); + } + + test("does not offer quoted cost concerns owned only by the research follow-up", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("quoted-cost", "They asked, ‘I don't know current provider prices.’", 11); + let result = await detectResearchOffer(state, message, { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: "I don't know current provider prices.", + triageOwned: 0.1, + owned: 0.99, + }); + expect(result.researchOffer).toBeUndefined(); + }); + + test("requires an open two-option thread and confident, unanswered, speaker-owned evidence", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("guarded-cost", "I don't know current provider prices.", 11); + let base: ResearchJudgment = { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + }; + let lowConfidence: JevAnswer = { + type: "choice", + choice: postmarkId, + confidence: 0.79, + probabilities: { [postmarkId]: 0.95, [sesId]: 0.05, none: 0 }, + }; + let cases: Array<{ state: ConversationPlan.State; judgment: ResearchJudgment }> = [ + { state, judgment: { ...base, need: 0.79 } }, + { state, judgment: { ...base, answered: 0.95 } }, + { state, judgment: { ...base, optionAAnswer: lowConfidence } }, + { state, judgment: { ...base, triageOwned: 0.1, owned: 0.1 } }, + { + state: seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + ]), + judgment: base, + }, + { + state: seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ], "decided"), + judgment: base, + }, + ]; + for (let item of cases) { + let result = await detectResearchOffer(item.state, message, item.judgment); + expect(result.researchOffer).toBeUndefined(); + } + }); + + test("suppresses a dismissed same-context paraphrase without starting research", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let first = entry("dismiss-first", "I don't know current provider prices.", 11); + let second = entry("dismiss-paraphrase", "We still need current provider pricing.", 12); + let setup = harness( + state, + inputInterpreter(message => ({ + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + })), + ); + let starts = 0; + await processMessage(setup, first); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer?.status).toBe("offered"); + let dismissed = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "dismiss", actionId: "dismiss-first" }, + ada, + "ada-principal", + async () => { + starts++; + return { execution: "started", researchRequestId: "should-not-start" }; + }, + ); + expect(dismissed.execution).toBe("none"); + await processMessage(setup, second); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.researchOffers?.[0]?.status).toBe("dismissed"); + expect(starts).toBe(0); + setup.processor.stop(); + }); + + test("persists valid planning events when the optional offer is rejected", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("planning-survives", "Postmark is the current preference.", 11); + let candidate: NonNullable = { + source: exactSource(message, message.text), + threadId: "mail", + optionIds: [postmarkId, "not-a-current-option"], + }; + let event: ConversationPlan.Event = { + id: "support-postmark", + type: "stance.changed", + threadId: "mail", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { ...candidate.source, role: "support" }, + optionId: postmarkId, + position: "support", + }; + let setup = harness(state, async () => ({ + events: [event], + researchOffer: candidate, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "applied", + passes: [], + }, + })); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.threads[0]?.stances).toContainEqual( + expect.objectContaining({ participant: "ada", optionId: postmarkId, position: "support" }), + ); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)?.eventIds) + .toContain(event.id); + setup.processor.stop(); + }); + + test("publishes no offer when the offer-and-analysis commit fails", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("failed-offer-commit", "I don't know current provider prices.", 11); + let setup = harness( + state, + inputInterpreter(() => ({ + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + })), + ); + setup.failPersistenceAfter(2); + await processMessage(setup, message); + expect(setup.errors.length).toBeGreaterThan(0); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.publications.every(snapshot => !snapshot.researchOffers?.length)).toBe(true); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts b/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts new file mode 100644 index 00000000..8bb7b303 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import type { JevAnswer } from "./jev"; +import { renderResearchTask } from "./validation"; +import { + d03Options, + d04Options, + entry, + exactSource, + expectNoDurableOffer, + harness, + inputInterpreter, + postmarkCopyId, + postmarkId, + processMessage, + r2Id, + relayCopyId, + relayId, + resendId, + s3Id, + seededThread, + sesCopyId, + sesId, + sharedDiskId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("three- and four-option concern admission fails closed on weak or stale evidence", async () => { + let state = d03Options(); + let generic = entry("weak-three-option", "I don't know current provider prices.", 11); + let ids = [relayId, postmarkId, sesId]; + let base: ResearchJudgment = { + threadId: "mail", + optionIds: ids, + quote: generic.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let fourOptions = seededThread("four-providers", "Which mail provider?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + { id: resendId, text: "Resend" }, + ]); + let fourOptionMessage = entry( + "four-option-price-need", + "I don't know current provider prices.", + 11, + ); + let fourOptionJudgment: ResearchJudgment = { + threadId: "four-providers", + optionIds: [relayId, postmarkId, sesId, resendId], + quote: fourOptionMessage.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let fourOptionSetup = harness( + fourOptions, + inputInterpreter(() => fourOptionJudgment), + ); + await processMessage(fourOptionSetup, fourOptionMessage); + let fourOptionOffer = fourOptionSetup.durable.state.researchOffers?.[0]; + expect(fourOptionOffer).toMatchObject({ + status: "offered", + source: exactSource(fourOptionMessage, fourOptionMessage.text), + task: { + kind: "current-cost-concern", + options: [ + { id: relayId, labelAtOffer: "SMTP relay" }, + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + { id: resendId, labelAtOffer: "Resend" }, + ], + }, + }); + expect(fourOptionOffer?.brief).toBe( + renderResearchTask(fourOptionOffer!.task!, fourOptionOffer!.source), + ); + expect(fourOptionSetup.durable.pending).toEqual([]); + expect(fourOptionSetup.durable.receipts).toEqual([]); + fourOptionSetup.processor.stop(); + + await expectNoDurableOffer(state, generic, { ...base, triageOwned: 0.1 }); + await expectNoDurableOffer(state, generic, { ...base, owned: 0.1 }); + await expectNoDurableOffer(state, generic, { ...base, answered: 0.95 }); + + let quoted = entry( + "quoted-three-option", + "Mina asked, ‘I don't know current provider prices.’", + 12, + ); + await expectNoDurableOffer(state, quoted, { + ...base, + quoteChoice: "none", + }); + let reported = entry( + "reported-three-option", + "Mina reported uncertainty about provider prices.", + 13, + ); + await expectNoDurableOffer(state, reported, { + ...base, + quoteChoice: "none", + }); + let conditional = entry( + "conditional-three-option", + "If prices are unclear, we could research later.", + 14, + ); + await expectNoDurableOffer(state, conditional, { + ...base, + quoteChoice: "none", + }); + + let weakThread: JevAnswer = { + type: "choice", + choice: "none", + confidence: 0.7, + probabilities: { mail: 0.6, none: 0.4 }, + }; + await expectNoDurableOffer(state, generic, { ...base, threadAnswer: weakThread }); + + let ambiguous = d03Options(); + ambiguous = applyEvent(ambiguous, { + id: "open-mail-copy", + type: "thread.opened", + threadId: "mail-copy", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: ambiguous.events.length + 1, + question: "Which provider should handle email delivery?", + }); + for (let [index, id] of [relayCopyId, postmarkCopyId, sesCopyId].entries()) { + ambiguous = applyEvent(ambiguous, { + id: `copy-option-${id}`, + type: "option.added", + threadId: "mail-copy", + observedThreadVersion: ambiguous.threads.at(-1)!.version, + origin: "planner", + actor: { kind: "agent" }, + at: ambiguous.events.length + 1, + contribution: { + id, + text: ["SMTP relay", "Postmark", "Amazon SES"][index]!, + authoring: "scribe", + }, + }); + } + ambiguous = applyEvent(ambiguous, { + id: "link-mail-copy", + type: "card.linked", + threadId: "mail-copy", + observedThreadVersion: ambiguous.threads.at(-1)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: ambiguous.events.length + 1, + questionnaireId: "card-mail-copy", + }); + await expectNoDurableOffer(ambiguous, generic, base); + + let five = seededThread("five-providers", "Which mail provider?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + { id: resendId, text: "Resend" }, + { id: sharedDiskId, text: "Mailgun" }, + ]); + await expectNoDurableOffer(five, generic, { + ...base, + threadId: "five-providers", + optionIds: [relayId, postmarkId, sesId, resendId, sharedDiskId], + }); + + let storage = d04Options(); + storage = applyEvent(storage, { + id: "rename-r2-before-cost-concern", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: storage.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Backblaze B2", + observedCardRevision: 0, + }); + let stale = entry("stale-three-option", "R2's egress could matter for thumbnails.", 11); + await expectNoDurableOffer(storage, stale, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: stale.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }); + + let foreign = applyEvent(d04Options(), { + id: "open-foreign-option-thread", + type: "thread.opened", + threadId: "other", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + question: "Which queue?", + }); + foreign = applyEvent(foreign, { + id: "add-foreign-option", + type: "option.added", + threadId: "other", + observedThreadVersion: foreign.threads[1]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 11, + contribution: { id: resendId, text: "Resend", authoring: "scribe" }, + }); + let egress = entry("foreign-focus", "R2's egress could matter for thumbnails.", 12); + await expectNoDurableOffer(foreign, egress, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: egress.text, + kind: "current-cost-concern", + scopeAnswer: { + type: "choice", + choice: resendId, + confidence: 0.95, + probabilities: { [resendId]: 0.95, none: 0.05 }, + }, + }); + + let multiName = entry( + "multi-name-three-option", + "R2's egress and Amazon S3 request prices could matter.", + 13, + ); + await expectNoDurableOffer(d04Options(), multiName, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: multiName.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }); + let explicitPair = entry( + "explicit-pair-three-options", + "Compare Amazon S3 and Cloudflare R2 costs.", + 14, + ); + await expectNoDurableOffer(d04Options(), explicitPair, { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: explicitPair.text, + kind: "current-cost-comparison", + }); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts b/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts new file mode 100644 index 00000000..9b4aeade --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import type { Interpretation } from "./interpret"; +import { renderResearchTask } from "./validation"; +import { + ada, + d03Options, + d04Options, + detectResearchOffer, + entry, + exactSource, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + s3Id, + seededThread, + sesId, + sharedDiskId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("uses a relabeled option's effective card label without starting work", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "S3" }, + { id: r2Id, text: "R2" }, + ]); + state = applyEvent(state, { + id: "rename-r2-for-card", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Cloudflare R2", + observedCardRevision: 0, + }); + let message = entry("D04-m5", "Cloudflare R2's egress could matter for thumbnails.", 11); + let quote = "Cloudflare R2's egress could matter for thumbnails."; + let candidate: NonNullable = { + source: exactSource(message, quote), + threadId: "storage", + optionIds: [s3Id, r2Id], + }; + let setup = harness(state, async () => ({ + events: [], + researchOffer: candidate, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + })); + let starts = 0; + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: candidate.source, + task: { + kind: "current-cost-comparison", + threadId: "storage", + options: [ + { id: s3Id, labelAtOffer: "S3" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + expect(starts).toBe(0); + + let consent = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "research", actionId: "click-research" }, + ada, + "ada-principal", + async accepted => { + starts++; + expect(accepted.brief).toBe(offer!.brief); + return { execution: "started", researchRequestId: "request-1" }; + }, + ); + expect(consent.execution).toBe("started"); + expect(starts).toBe(1); + setup.processor.stop(); + }); + + test("detects D03's generic two-option cost need without starting work before consent", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("D03-m4", "I don't know current provider prices.", 11); + let quote = message.text; + let judgment: ResearchJudgment = { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + threadId: "mail", + optionIds: [postmarkId, sesId], + source: exactSource(message, quote), + }); + + let setup = harness(state, inputInterpreter(() => judgment), [message]); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, quote), + task: { + kind: "current-cost-comparison", + threadId: "mail", + options: [ + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + ], + }, + brief: `Compare current costs for Postmark and Amazon SES. Discussion context: “${quote}”`, + }); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); + + test("D03 generic provider-price uncertainty offers all three options without a pair or focus", async () => { + let state = d03Options(); + let message = entry("D03-m4-three-options", "I don't know current provider prices.", 11); + let judgment: ResearchJudgment = { + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }); + expect((interpretation.researchOffer as unknown as { focusOptionId?: string })?.focusOptionId) + .toBeUndefined(); + + let setup = harness(state, inputInterpreter(() => judgment), [message]); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "mail", + options: [ + { id: relayId, labelAtOffer: "SMTP relay" }, + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + ], + }, + }); + expect((offer?.task as unknown as { focusOptionId?: string })?.focusOptionId).toBeUndefined(); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + for (let label of ["SMTP relay", "Postmark", "Amazon SES", message.text]) { + expect(offer?.brief).toContain(label); + } + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); + + test("D04 R2 egress focuses R2 while S3 and shared disk remain in context", async () => { + let state = d04Options(); + let message = entry("D04-m5-three-options", "R2's egress could matter for thumbnails.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + kind: "current-cost-concern", + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + source: exactSource(message, message.text), + }); + expect((interpretation.researchOffer as unknown as { focusOptionId?: string })?.focusOptionId) + .toBe(r2Id); + + let setup = harness(state, inputInterpreter(() => judgment)); + let starts = 0; + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "storage", + focusOptionId: r2Id, + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: sharedDiskId, labelAtOffer: "shared disk" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(offer?.brief).toContain( + "Investigate current costs for Cloudflare R2; consider Amazon S3, shared disk as context.", + ); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + expect(starts).toBe(0); + let consent = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "research", actionId: "D04-click-research" }, + ada, + "ada-principal", + async accepted => { + starts++; + expect(accepted.brief).toBe(offer!.brief); + return { execution: "started", researchRequestId: "D04-request" }; + }, + ); + expect(consent.execution).toBe("started"); + expect(starts).toBe(1); + setup.processor.stop(); + }); + + test("D04 focuses R2 when its current option label contains a longer question", async () => { + let state = seededThread("storage", "Where should uploaded images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "is R2 still worth comparing, or did we rule it out?" }, + ]); + let message = entry("D04-long-r2-label", "R2's egress could matter for thumbnails.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "storage", + focusOptionId: r2Id, + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: sharedDiskId, labelAtOffer: "shared disk" }, + { + id: r2Id, + labelAtOffer: "is R2 still worth comparing, or did we rule it out?", + }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(offer?.source).toEqual(exactSource(message, message.text)); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts b/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts new file mode 100644 index 00000000..83c79fdf --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts @@ -0,0 +1,348 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import type { Effect } from "./effects"; +import { applyEvent } from "./events"; +import { interpretMessage } from "./interpret"; +import type { JevAnswer, JevQuestion, JevRequest, JevResult } from "./jev"; +import { createProcessor, type Dependencies } from "./service"; + +// Whole archived detection fixture declarations; export modifiers only. +export type Member = Extract; + +export type Override = string | number | JevAnswer; + +export let ada: Member = { kind: "member", handle: "ada" }; + +export let postmarkId = "01K00000000000000000000011"; + +export let sesId = "01K00000000000000000000012"; + +export let s3Id = "01K00000000000000000000013"; + +export let r2Id = "01K00000000000000000000014"; + +export let resendId = "01K00000000000000000000015"; + +export let relayId = "01K00000000000000000000016"; + +export let sharedDiskId = "01K00000000000000000000017"; + +export let relayCopyId = "01K00000000000000000000018"; + +export let postmarkCopyId = "01K00000000000000000000019"; + +export let sesCopyId = "01K0000000000000000000001A"; + +export function entry(id: string, text: string, ts = 1): Chat.Entry { + return { id, text, ts, author: ada }; +} + +export function seededThread( + threadId: string, + question: string, + options: Array<{ id: string; text: string }>, + status: "open" | "decided" = "open", +): ConversationPlan.State { + let state = applyEvent(initialState(), { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question, + }); + for (let option of options) { + state = applyEvent(state, { + id: `add-${option.id}`, + type: "option.added", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: state.events.length + 1, + contribution: { id: option.id, text: option.text, authoring: "scribe" }, + }); + } + state = applyEvent(state, { + id: `link-${threadId}`, + type: "card.linked", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: state.events.length + 1, + questionnaireId: `card-${threadId}`, + }); + if (status === "decided") { + state = applyEvent(state, { + id: `decide-${threadId}`, + type: "decision.recorded", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: ada, + at: state.events.length + 1, + text: `Use ${options[0]!.text}`, + optionId: options[0]!.id, + explicit: true, + }); + } + return state; +} + +export function d03Options(threadId = "mail") { + return seededThread(threadId, "Which email delivery service should we use?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); +} + +export function d04Options(threadId = "storage") { + return seededThread(threadId, "Where should uploaded images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); +} + +export function answerResult( + questions: Record, + overrides: Record = {}, +): JevResult { + let answers: Record = {}; + for (let [key, question] of Object.entries(questions)) { + let override = overrides[key]; + if (question.type === "noul") { + answers[key] = override && typeof override === "object" + ? override + : { type: "noul", noul: typeof override === "number" ? override : 0.05 }; + } else if (question.type === "choice") { + if (override && typeof override === "object") { + answers[key] = override; + continue; + } + let choices = Object.keys(question.criteria); + let choice = typeof override === "string" && choices.includes(override) + ? override + : choices.includes("none") + ? "none" + : choices[0]!; + let remainder = (1 - 0.95) / Math.max(choices.length - 1, 1); + answers[key] = { + type: "choice", + choice, + confidence: 0.95, + probabilities: Object.fromEntries( + choices.map(item => [item, item === choice ? 0.95 : remainder]), + ), + }; + } else { + let levels = question.criteria; + answers[key] = { + type: "score", + score: 0, + confidence: 1, + legend: Object.fromEntries(levels.map((label, index) => [String(index), label])), + probabilities: Object.fromEntries( + levels.map((_, index) => [String(index), index === 0 ? 1 : 0]), + ), + }; + } + } + return { model: "fake-jev", answers, usage: { input_tokens: 1, output_tokens: 1 }, latencyMs: 1 }; +} + +export type ResearchJudgment = { + threadId: string; + optionIds: string[]; + quote: string; + kind?: "current-cost-comparison" | "current-cost-concern"; + scopeChoice?: string; + scopeAnswer?: JevAnswer; + threadAnswer?: JevAnswer; + need?: number; + triageOwned?: number; + owned?: number; + answered?: number; + quoteChoice?: string; + optionAAnswer?: JevAnswer; +}; + +export function fakeJev(judgment: ResearchJudgment) { + return async (request: JevRequest): Promise => { + let overrides: Record = {}; + if ("research_need" in request.questions) { + overrides.research_need = judgment.need ?? 0.95; + for (let key of Object.keys(request.questions)) { + if (key.endsWith("_owned_unretracted")) { + overrides[key] = judgment.triageOwned ?? 0.95; + } + } + } else if ("research_quote" in request.questions) { + let quoteQuestion = request.questions.research_quote; + let quoteChoices = quoteQuestion?.type === "choice" ? quoteQuestion.criteria : {}; + overrides.research_quote = judgment.quoteChoice + ?? Object.entries(quoteChoices).find(([, description]) => description === judgment.quote) + ?.[0] + ?? "none"; + overrides.research_thread = judgment.threadAnswer ?? judgment.threadId; + overrides.research_option_a = judgment.optionAAnswer + ?? (judgment.kind === "current-cost-concern" ? "none" : judgment.optionIds[0]); + overrides.research_option_b = judgment.kind === "current-cost-concern" + ? "none" + : judgment.optionIds[1]; + overrides.research_kind = judgment.kind ?? "current-cost-comparison"; + let quoteKey = overrides.research_quote; + let scopeKey = typeof quoteKey === "string" ? `research_${quoteKey}_scope` : ""; + if (judgment.kind === "current-cost-concern" && scopeKey in request.questions) { + overrides[scopeKey] = judgment.scopeAnswer ?? judgment.scopeChoice ?? "all-current"; + } + let ownerQuestion = typeof overrides.research_quote === "string" + ? `research_${overrides.research_quote}_owned` + : "research_q0_owned"; + overrides[ownerQuestion] = judgment.owned ?? 0.95; + let answeredQuestion = typeof overrides.research_quote === "string" + ? `research_${overrides.research_quote}_answered` + : "research_q0_answered"; + overrides[answeredQuestion] = judgment.answered ?? 0.05; + } + return answerResult(request.questions, overrides); + }; +} + +export function inputInterpreter(judgmentFor: (message: Chat.Entry) => ResearchJudgment) { + return (input: Parameters>[0]) => + interpretMessage({ ...input, ask: fakeJev(judgmentFor(input.message)) }); +} + +export function detectResearchOffer( + state: ConversationPlan.State, + message: Chat.Entry, + judgment: ResearchJudgment, +) { + return interpretMessage({ + channelId: "channel", + message, + recent: [], + state, + ask: fakeJev(judgment), + }); +} + +export function harness( + state: ConversationPlan.State, + interpret: NonNullable, + entries: Chat.Entry[] = [], +) { + let plan = { + id: "channel", + chat: { entries: [...entries] }, + conversationPlan: state, + conversationPlanRetries: [], + conversationPlanEffects: [] as string[], + conversationPlanPendingEffects: [] as Effect[], + pendingCardActions: [], + records: new Map(), + } as unknown as Dependencies["plan"]; + let durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + pending: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + }); + let publications: ConversationPlan.State[] = []; + let errors: unknown[] = []; + let persistCount = 0; + let failAt: number | undefined; + let tail = Promise.resolve(); + let dependencies: Dependencies = { + plan, + exclusive: action => { + let operation = tail.then(action, action); + tail = operation.then(() => {}, () => {}); + return operation; + }, + persist: async () => { + persistCount++; + if (persistCount === failAt) throw new Error("storage failed"); + durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + pending: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + }); + }, + publish: next => { + expect(durable.state).toEqual(next); + publications.push(structuredClone(next)); + }, + active: () => true, + interpret, + onError: error => errors.push(error), + }; + return { + plan, + dependencies, + processor: createProcessor(dependencies), + publications, + errors, + get durable() { + return durable; + }, + get persistCount() { + return persistCount; + }, + failPersistenceAfter(offset: number) { + failAt = persistCount + offset; + }, + }; +} + +export async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 400; attempt++) { + if (condition()) return; + await Bun.sleep(1); + } + throw new Error("processor did not reach the expected state"); +} + +export async function processMessage( + setup: ReturnType, + message: Chat.Entry, +): Promise { + await setup.processor.accept(message); + setup.processor.afterMessage(); + await waitFor(() => + setup.errors.length > 0 || setup.durable.state.analysis.some( + item => item.messageId === message.id, + ) + ); +} + +export function exactSource(message: Chat.Entry, quote: string): ConversationPlan.ResearchSource { + let start = message.text.indexOf(quote); + if (start < 0) throw new Error("quote missing from synthetic message"); + return { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote, + start, + end: start + quote.length, + }; +} + +export async function expectNoDurableOffer( + state: ConversationPlan.State, + message: Chat.Entry, + judgment: ResearchJudgment, +): Promise { + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)).toBe(true); + setup.processor.stop(); +} diff --git a/apps/server/src/conversation-plan/research-offers-comparison.test.ts b/apps/server/src/conversation-plan/research-offers-comparison.test.ts new file mode 100644 index 00000000..72596a72 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers-comparison.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { actOnResearchOffer, offerResearch, restoreState } from "./domain"; +import { renderResearchTask } from "./validation"; +import { applyEvent } from "./events"; +import { + ada, + message, + optionThread, + r2Id, + s3Id, + taskProposal, +} from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("freezes two current option labels and a deterministic cost brief", () => { + let entry = message("cost-source", "R2 egress could matter for thumbnails."); + let state = optionThread(); + let offer = taskProposal(entry, state); + let saved = offerResearch(state, offer, entry); + expect(saved.researchOffers?.[0]?.brief).toBe( + "Compare current costs for S3 and R2. Discussion context: “R2 egress could matter for thumbnails.”", + ); + let accepted = actOnResearchOffer(saved, offer.id, { + id: "accept-cost", + kind: "research", + actor: ada, + principalId: "ada-id", + at: 10, + }); + expect(restoreState(accepted, [entry])).toEqual(accepted); + let linked = applyEvent(accepted, { + id: "link", + type: "card.linked", + threadId: "hosting", + observedThreadVersion: accepted.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 11, + questionnaireId: "card-1", + }); + let relabeled = applyEvent(linked, { + id: "rename", + type: "option.relabeled", + threadId: "hosting", + observedThreadVersion: linked.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 12, + optionId: r2Id, + label: "Cloudflare R2", + observedCardRevision: 0, + }); + expect(restoreState(relabeled, [entry])).toEqual(relabeled); + expect(relabeled.researchOffers?.[0]?.task?.options[1].labelAtOffer).toBe("R2"); + expect(offerResearch(relabeled, offer, entry)).toBe(relabeled); + }); + + test("rejects invented, foreign, duplicate, or changed task labels and briefs", () => { + let entry = message("cost-source", "Compare our current providers."); + let state = optionThread(); + let base = taskProposal(entry, state); + let altered = (change: (offer: typeof base) => void) => { + let candidate = structuredClone(base); + change(candidate); + return candidate; + }; + expect(() => + offerResearch( + state, + altered(offer => { + offer.brief = "Invented task"; + }), + entry, + ) + ) + .toThrow(/brief/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].labelAtOffer = "Azure Blob"; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].id = "foreign"; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + let otherThread = applyEvent(state, { + id: "open-other", + type: "thread.opened", + threadId: "other", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 8, + question: "Which queue?", + }); + otherThread = applyEvent(otherThread, { + id: "add-other", + type: "option.added", + threadId: "other", + observedThreadVersion: otherThread.threads[1]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 9, + contribution: { + id: "01K00000000000000000000003", + text: "Redis", + authoring: "scribe", + }, + }); + expect(() => + offerResearch( + otherThread, + altered(offer => { + offer.task!.options[1] = { + id: "01K00000000000000000000003", + labelAtOffer: "Redis", + }; + offer.brief = renderResearchTask(offer.task!, offer.source); + }), + entry, + ) + ).toThrow(/current thread/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].id = s3Id; + }), + entry, + ) + ).toThrow(/duplicate/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.task!.options[1].labelAtOffer = "x".repeat(161); + }), + entry, + ) + ).toThrow(/text/); + expect(() => + offerResearch( + state, + altered(offer => { + offer.threadId = "foreign-thread"; + }), + entry, + ) + ).toThrow(/thread/); + let saved = offerResearch(state, base, entry); + let forged = structuredClone(saved); + forged.researchOffers![0].task!.options[1].labelAtOffer = "Azure Blob"; + forged.researchOffers![0].brief = renderResearchTask( + forged.researchOffers![0].task!, + forged.researchOffers![0].source, + ); + expect(() => restoreState(forged, [entry])).toThrow(/history/); + let wrongSource = structuredClone(saved); + wrongSource.researchOffers![0].source.quote = "forged exact quote"; + wrongSource.researchOffers![0].source.end = "forged exact quote".length; + wrongSource.researchOffers![0].brief = renderResearchTask( + wrongSource.researchOffers![0].task!, + wrongSource.researchOffers![0].source, + ); + expect(() => restoreState(wrongSource, [entry])).toThrow(/source quote/); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offers-concern.test.ts b/apps/server/src/conversation-plan/research-offers-concern.test.ts new file mode 100644 index 00000000..fd9f87cc --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers-concern.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { restoreState } from "./domain"; +import { renderResearchTask } from "./validation"; +import { applyEvent } from "./events"; +import { + ada, + costConcernProposal, + type CostConcernTask, + diskId, + exactSource, + mailgunId, + message, + offerCostConcern, + postmarkId, + r2Id, + s3Id, + sesId, + stateWithOptions, +} from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("generic current-price uncertainty freezes all providers without a chosen focus", () => { + let entry = message("d03-cost-source", "I don't know current provider prices."); + let state = stateWithOptions("providers", "Which email provider?", [ + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + { id: mailgunId, label: "Mailgun" }, + ]); + let candidate = costConcernProposal(entry, state, undefined, entry.text, "providers"); + let saved = offerCostConcern(state, candidate, entry); + let offer = saved.researchOffers?.[0]; + if (!offer) throw new Error("cost concern was not stored"); + expect((offer.task as unknown as CostConcernTask).kind).toBe("current-cost-concern"); + expect((offer.task as unknown as CostConcernTask).options).toEqual([ + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + { id: mailgunId, labelAtOffer: "Mailgun" }, + ]); + expect((offer.task as unknown as CostConcernTask).focusOptionId).toBeUndefined(); + expect(offer.source).toEqual(exactSource(entry)); + expect(offer.brief).toBe(renderResearchTask( + offer.task as unknown as ConversationPlan.ResearchTask, + offer.source, + )); + for (let label of ["Postmark", "Amazon SES", "Mailgun", entry.text]) { + expect(offer.brief).toContain(label); + } + expect(restoreState(structuredClone(saved), [entry])).toEqual(saved); + }); + + test("an R2 egress concern focuses R2 while retaining every storage option as context", () => { + let entry = message("d04-egress-source", "R2 egress could matter for thumbnails."); + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let candidate = costConcernProposal(entry, state, r2Id); + let saved = offerCostConcern(state, candidate, entry); + let offer = saved.researchOffers?.[0]; + if (!offer) throw new Error("cost concern was not stored"); + expect((offer.task as unknown as CostConcernTask).kind).toBe("current-cost-concern"); + expect((offer.task as unknown as CostConcernTask).options).toEqual([ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + { id: diskId, labelAtOffer: "Local disk" }, + ]); + expect((offer.task as unknown as CostConcernTask).focusOptionId).toBe(r2Id); + expect(offer.source).toEqual(exactSource(entry)); + expect(offer.brief).toBe(renderResearchTask( + offer.task as unknown as ConversationPlan.ResearchTask, + offer.source, + )); + for (let label of ["Amazon S3", "Cloudflare R2", "Local disk", entry.text]) { + expect(offer.brief).toContain(label); + } + expect(restoreState(structuredClone(saved), [entry])).toEqual(saved); + }); + + test("multi-option concerns reject stale labels, duplicate IDs, and an invalid focus", () => { + let entry = message("d04-stale-source", "R2 egress could matter for thumbnails."); + entry.ts = 1_800_000_000; + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let candidate = costConcernProposal(entry, state, r2Id); + let offered = offerCostConcern(state, candidate, entry); + let renamed = applyEvent(offered, { + id: "human:rename-r2", + type: "card.corrected", + threadId: "storage", + observedThreadVersion: offered.threads[0]!.version, + origin: "human", + actor: ada, + at: 1_800_000_001_000, + change: { kind: "edit", field: "contribution", contributionId: r2Id, text: "Backblaze B2" }, + }); + expect(restoreState(structuredClone(renamed), [entry])).toEqual(renamed); + let afterRename = message("d04-after-rename", entry.text); + afterRename.ts = 1_800_000_002; + let stale = costConcernProposal(afterRename, state, r2Id); + expect(() => offerCostConcern(renamed, stale, afterRename)).toThrow(/current thread/); + expect(() => restoreState(structuredClone(renamed), [{ ...entry, ts: afterRename.ts }])) + .toThrow(/postdates/); + + let duplicate = structuredClone(candidate); + duplicate.task.options[2]!.id = duplicate.task.options[1]!.id; + duplicate.brief = renderResearchTask( + duplicate.task as unknown as ConversationPlan.ResearchTask, + duplicate.source, + ); + expect(() => offerCostConcern(state, duplicate, entry)).toThrow(/duplicate/); + + let invalidFocus = structuredClone(candidate); + invalidFocus.task.focusOptionId = postmarkId; + invalidFocus.brief = renderResearchTask( + invalidFocus.task as unknown as ConversationPlan.ResearchTask, + invalidFocus.source, + ); + let rejected = false; + try { + let result = offerCostConcern(state, invalidFocus, entry); + rejected = result.researchOffers?.length !== 1; + } catch { + rejected = true; + } + expect(rejected).toBe(true); + }); + + test("an old R2-only quote cannot become a generic concern after R2 is relabeled", () => { + let state = stateWithOptions("storage", "Where should we store uploaded images?", [ + { id: s3Id, label: "Amazon S3" }, + { id: r2Id, label: "Cloudflare R2" }, + { id: diskId, label: "Local disk" }, + ]); + let renamed = applyEvent(state, { + id: "human:rename-r2-before-new-concern", + type: "card.corrected", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: ada, + at: 1_800_000_001_000, + change: { kind: "edit", field: "contribution", contributionId: r2Id, text: "Backblaze B2" }, + }); + let entry = message("d04-old-r2-name", "R2 egress could matter for thumbnails."); + let candidate = costConcernProposal(entry, renamed, undefined); + let rejected = false; + try { + let result = offerCostConcern(renamed, candidate, entry); + rejected = result.researchOffers?.length !== 1; + } catch { + rejected = true; + } + expect(candidate.task.focusOptionId).toBeUndefined(); + expect(rejected).toBe(true); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offers.test-fixtures.ts b/apps/server/src/conversation-plan/research-offers.test-fixtures.ts new file mode 100644 index 00000000..f619a8c6 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers.test-fixtures.ts @@ -0,0 +1,185 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState, offerResearch } from "./domain"; +import { renderResearchTask } from "./validation"; +import { applyEvent } from "./events"; + +export function message(id: string, text = "Look up the 🧵 hosting terms"): Chat.Entry { + return { id, author: { kind: "member", handle: "ada" }, text, ts: 1 }; +} + +export function proposal( + entry: Chat.Entry, + id = `offer:${entry.id}`, + needId = "hosting-terms", + contextId = "v1", +): Omit { + return { + id, + needId, + contextId, + brief: entry.text, + source: { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote: entry.text, + start: 0, + end: entry.text.length, + }, + }; +} + +export let ada: Extract = { kind: "member", handle: "ada" }; +export let bo: Extract = { kind: "member", handle: "bo" }; +export let s3Id = "01K00000000000000000000001"; +export let r2Id = "01K00000000000000000000002"; +export let diskId = "01K00000000000000000000003"; +export let postmarkId = "01K00000000000000000000004"; +export let sesId = "01K00000000000000000000005"; +export let mailgunId = "01K00000000000000000000006"; + +export type CostConcernTask = { + kind: "current-cost-concern"; + threadId: string; + observedEventCount: number; + observedThreadVersion: number; + options: Array<{ id: string; labelAtOffer: string }>; + focusOptionId?: string; +}; + +export type CostConcernProposal = + & Omit + & { task: CostConcernTask }; + +export function optionThread(): ConversationPlan.State { + let state = initialState(); + state = applyEvent(state, { + id: "open", + type: "thread.opened", + threadId: "hosting", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which storage?", + }); + for (let [id, label] of [[s3Id, "S3"], [r2Id, "R2"]]) { + state = applyEvent(state, { + id: `add-${id}`, + type: "option.added", + threadId: "hosting", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: state.revision + 1, + contribution: { id, text: label, authoring: "scribe" }, + }); + } + return state; +} + +export function taskProposal( + entry: Chat.Entry, + state: ConversationPlan.State, +): Omit { + let task: ConversationPlan.ResearchTask = { + kind: "current-cost-comparison", + threadId: "hosting", + observedEventCount: state.events.length, + observedThreadVersion: state.threads[0]!.version, + options: [{ id: s3Id, labelAtOffer: "S3" }, { id: r2Id, labelAtOffer: "R2" }], + }; + return { + ...proposal(entry), + threadId: "hosting", + task, + brief: renderResearchTask(task, proposal(entry).source), + }; +} + +export function stateWithOptions( + threadId: string, + question: string, + options: Array<{ id: string; label: string }>, +): ConversationPlan.State { + let state = applyEvent(initialState(), { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question, + }); + for (let option of options) { + state = applyEvent(state, { + id: `add-${option.id}`, + type: "option.added", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: state.revision + 1, + contribution: { id: option.id, text: option.label, authoring: "scribe" }, + }); + } + return state; +} + +export function exactSource( + entry: Chat.Entry, + quote = entry.text, +): ConversationPlan.ResearchSource { + let start = entry.text.indexOf(quote); + if (start < 0) throw new Error("quote is missing from message"); + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote, + start, + end: start + quote.length, + }; +} + +export function costConcernProposal( + entry: Chat.Entry, + state: ConversationPlan.State, + focusOptionId?: string, + quote = entry.text, + threadId = "storage", +): CostConcernProposal { + let thread = state.threads.find(item => item.id === threadId)!; + let source = exactSource(entry, quote); + let task: CostConcernTask = { + kind: "current-cost-concern", + threadId: thread.id, + observedEventCount: state.events.length, + observedThreadVersion: thread.version, + options: thread.contributions + .filter(item => item.kind === "option") + .map(item => ({ id: item.id, labelAtOffer: item.displayLabel ?? item.text })), + ...(focusOptionId ? { focusOptionId } : {}), + }; + return { + ...proposal(entry, `offer:${entry.id}`, "current-cost-concern", "storage-v1"), + source, + threadId: thread.id, + task, + brief: renderResearchTask( + task as unknown as ConversationPlan.ResearchTask, + source, + ), + }; +} + +export function offerCostConcern( + state: ConversationPlan.State, + proposal: CostConcernProposal, + entry: Chat.Entry, +) { + return offerResearch( + state, + proposal as unknown as Omit, + entry, + ); +} diff --git a/apps/server/src/conversation-plan/research-offers.test.ts b/apps/server/src/conversation-plan/research-offers.test.ts new file mode 100644 index 00000000..5d5f868a --- /dev/null +++ b/apps/server/src/conversation-plan/research-offers.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { actOnResearchOffer, initialState, offerResearch, restoreState } from "./domain"; +import { MAX_RESEARCH_OFFERS } from "./validation"; +import { ada, bo, message, proposal } from "./research-offers.test-fixtures"; + +describe("research offer domain contract", () => { + test("keeps exact UTF-16 source and brief through restore; old sidecars remain valid", () => { + let entry = message("m1"); + let input = proposal(entry); + let state = offerResearch(initialState(), input, entry); + input.source.quote = "changed"; + expect(state.researchOffers?.[0]?.brief).toBe(entry.text); + expect(state.researchOffers?.[0]?.source.end).toBe(entry.text.length); + expect(restoreState(structuredClone(state), [entry])).toEqual(state); + let { researchOffers: _offers, ...old } = initialState(); + expect(restoreState(old).researchOffers).toBeUndefined(); + expect(offerResearch(old, proposal(entry), entry).researchOffers).toHaveLength(1); + }); + + test("rejects forged quote, range, author, brief and missing thread on creation or restore", () => { + let entry = message("m1"); + let base = proposal(entry); + let badQuote = structuredClone(base); + badQuote.source.quote = "forged"; + expect(() => offerResearch(initialState(), badQuote, entry)).toThrow(); + let badRange = structuredClone(base); + badRange.source.end -= 1; + expect(() => offerResearch(initialState(), badRange, entry)).toThrow(); + let badAuthor = structuredClone(base); + badAuthor.source.author = { kind: "member", handle: "bo" }; + expect(() => offerResearch(initialState(), badAuthor, entry)).toThrow(); + let badBrief = structuredClone(base); + badBrief.brief = "polished but not the quote"; + expect(() => offerResearch(initialState(), badBrief, entry)).toThrow(); + expect(() => + offerResearch( + initialState(), + { + ...base, + status: "accepted", + } as typeof base, + entry, + ) + ).toThrow(/proposal/); + expect(() => offerResearch(initialState(), { ...base, threadId: "absent" }, entry)) + .toThrow(/thread/); + let saved = offerResearch(initialState(), base, entry); + expect(() => restoreState(saved, [])).toThrow(/missing/); + let altered = structuredClone(saved); + altered.researchOffers![0].source.quote = "forged"; + expect(() => restoreState(altered, [entry])).toThrow(); + }); + + test("first terminal action wins, exact retries are no-ops, conflicting clicks fail", () => { + let entry = message("m1"); + let offered = offerResearch(initialState(), proposal(entry), entry); + expect(offerResearch(offered, proposal(entry), entry)).toBe(offered); + let research: ConversationPlan.ResearchAction = { + id: "act-1", + kind: "research", + actor: ada, + principalId: "ada-id", + at: 2, + }; + let accepted = actOnResearchOffer(offered, "offer:m1", research); + expect(accepted.researchOffers?.[0]).toMatchObject({ status: "accepted", action: research }); + expect(actOnResearchOffer(accepted, "offer:m1", research)).toBe(accepted); + expect(actOnResearchOffer(accepted, "offer:m1", { ...research, at: 99 })).toBe(accepted); + expect(accepted.researchOffers?.[0]?.action?.at).toBe(2); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + id: "act-2", + kind: "dismiss", + actor: bo, + principalId: "bo-id", + at: 3, + }) + ).toThrow(/terminal/); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + ...research, + principalId: "different-id", + }) + ).toThrow(/terminal/); + expect(() => + actOnResearchOffer(accepted, "offer:m1", { + ...research, + actor: bo, + }) + ).toThrow(/terminal/); + expect(restoreState(accepted, [entry])).toEqual(accepted); + let invalid = structuredClone(accepted); + invalid.researchOffers![0].status = "dismissed"; + expect(() => restoreState(invalid)).toThrow(/disagrees/); + let unacted = structuredClone(accepted); + unacted.researchOffers![0].action = undefined; + expect(() => restoreState(unacted)).toThrow(); + let noPrincipal = structuredClone(accepted); + noPrincipal.researchOffers![0].action!.principalId = ""; + expect(() => restoreState(noPrincipal)).toThrow(); + }); + + test("retains dismissed need/context suppression, permits a new context, fails closed at cap", () => { + let first = message("m1"); + let dismissed = actOnResearchOffer( + offerResearch(initialState(), proposal(first), first), + "offer:m1", + { id: "dismiss-1", kind: "dismiss", actor: ada, principalId: "ada-id", at: 2 }, + ); + let second = message("m2"); + expect(() => offerResearch(dismissed, proposal(second), second)).toThrow(/duplicate/); + let changed = offerResearch( + dismissed, + proposal(second, "offer:m2", "hosting-terms", "v2"), + second, + ); + expect(changed.researchOffers?.[0]?.status).toBe("dismissed"); + expect(changed.researchOffers).toHaveLength(2); + expect(() => + actOnResearchOffer(changed, "offer:m2", { + id: "dismiss-1", + kind: "dismiss", + actor: bo, + principalId: "bo-id", + at: 3, + }) + ).toThrow(/already used/); + let full = initialState(); + for (let index = 0; index < MAX_RESEARCH_OFFERS; index++) { + let entry = message(`m${index}`); + full = offerResearch(full, proposal(entry, `offer:${index}`, `need:${index}`), entry); + } + expect(() => + offerResearch(full, proposal(message("overflow"), "overflow", "new"), message("overflow")) + ).toThrow(/full/); + expect(full.researchOffers).toHaveLength(MAX_RESEARCH_OFFERS); + }); +}); diff --git a/apps/server/src/conversation-plan/research-snapshots.ts b/apps/server/src/conversation-plan/research-snapshots.ts new file mode 100644 index 00000000..ac13b94b --- /dev/null +++ b/apps/server/src/conversation-plan/research-snapshots.ts @@ -0,0 +1,91 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { isDeepStrictEqual } from "node:util"; +import { initialState } from "./domain-initial"; +import { applyEvent } from "./events"; +import { researchNamedOptionIds } from "./validation"; + +type State = ConversationPlan.State; +type Event = ConversationPlan.Event; + +export function taskOptionLabels(state: State, task: ConversationPlan.ResearchTask) { + let thread = state.threads.find(item => item.id === task.threadId); + return task.options.map(option => { + let contribution = thread?.contributions.find(item => + item.kind === "option" && item.id === option.id + ); + return contribution ? contribution.displayLabel ?? contribution.text : undefined; + }); +} + +export function taskSnapshot(state: State, task: ConversationPlan.ResearchTask) { + let labels = taskOptionLabels(state, task); + if (task.kind === "current-cost-comparison") return labels; + let thread = state.threads.find(item => item.id === task.threadId); + return { + labels, + currentOptionIds: thread?.contributions.filter(item => item.kind === "option") + .map(item => item.id), + }; +} + +export function taskMatches(state: State, task: ConversationPlan.ResearchTask): boolean { + let thread = state.threads.find(item => item.id === task.threadId); + return !!thread && thread.version === task.observedThreadVersion + && (task.kind === "current-cost-comparison" + || isDeepStrictEqual( + thread.contributions.filter(item => item.kind === "option").map(item => item.id), + task.options.map(item => item.id), + )) + && taskOptionLabels(state, task).every((label, index) => + label === task.options[index].labelAtOffer + ); +} + +export function namesStaleResearchOption( + state: State, + task: ConversationPlan.ResearchTask, + quote: string, +): boolean { + if (task.kind !== "current-cost-concern") return false; + let currentNames = new Set(researchNamedOptionIds(quote, task.options)); + let currentLabels = new Map(task.options.map(option => [option.id, option.labelAtOffer])); + let displayLabels = new Set(); + let oldName = (id: string, label: string) => + label !== currentLabels.get(id) && !currentNames.has(id) + && researchNamedOptionIds(quote, [{ id, labelAtOffer: label }]).length > 0; + for (let event of state.events.slice(0, task.observedEventCount)) { + if (event.type === "option.added" && currentLabels.has(event.contribution.id)) { + if (oldName(event.contribution.id, event.contribution.text)) return true; + } else if (event.type === "option.relabeled" && currentLabels.has(event.optionId)) { + displayLabels.add(event.optionId); + if (oldName(event.optionId, event.label)) return true; + } else if ( + event.type === "card.corrected" && event.change.kind === "edit" + && event.change.field === "contribution" + && currentLabels.has(event.change.contributionId) + && !displayLabels.has(event.change.contributionId) + ) { + if (oldName(event.change.contributionId, event.change.text)) return true; + } + } + return false; +} + +export function taskLabelChanges( + events: readonly Event[], + tasks: readonly ConversationPlan.ResearchOffer[], +) { + let changes = new Map(); + if (tasks.length === 0) return changes; + let state = initialState(); + for (let [eventIndex, event] of events.entries()) { + let before = tasks.map(offer => taskSnapshot(state, offer.task!)); + state = applyEvent(state, event); + for (let [index, offer] of tasks.entries()) { + if (!isDeepStrictEqual(before[index], taskSnapshot(state, offer.task!))) { + changes.set(offer.id, [...(changes.get(offer.id) ?? []), eventIndex]); + } + } + } + return changes; +} diff --git a/apps/server/src/conversation-plan/research-validation.test.ts b/apps/server/src/conversation-plan/research-validation.test.ts new file mode 100644 index 00000000..59e2c935 --- /dev/null +++ b/apps/server/src/conversation-plan/research-validation.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { assertResearchOfferShape, renderResearchTask, researchNamedOptionIds } from "./validation"; + +function legacy(): ConversationPlan.ResearchOffer { + return { + id: "offer-1", + needId: "need-1", + contextId: "context-1", + source: { + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote: "Compare costs", + start: 0, + end: 13, + }, + brief: "Compare costs", + status: "offered", + }; +} + +test("legacy research offers keep the exact source quote as their brief", () => { + expect(() => assertResearchOfferShape(legacy())).not.toThrow(); + expect(() => assertResearchOfferShape({ ...legacy(), brief: "Compare prices" })) + .toThrow("research brief must equal source quote"); +}); + +function comparison(): ConversationPlan.ResearchTask { + return { + kind: "current-cost-comparison", + threadId: "thread-1", + observedEventCount: 2, + observedThreadVersion: 1, + options: [{ id: "r2", labelAtOffer: "Cloudflare R2" }, { id: "s3", labelAtOffer: "Amazon S3" }], + }; +} +function taskOffer( + task: ConversationPlan.ResearchTask, + quote = "Compare costs", +): ConversationPlan.ResearchOffer { + let source = { ...legacy().source, quote, end: quote.length }; + return { + ...legacy(), + source, + threadId: "thread-1", + task, + brief: renderResearchTask(task, source), + }; +} + +test("comparison and concern briefs use frozen labels and exact source context", () => { + let pair = taskOffer(comparison()); + expect(pair.brief).toBe( + "Compare current costs for Cloudflare R2 and Amazon S3. Discussion context: “Compare costs”", + ); + expect(() => assertResearchOfferShape(pair)).not.toThrow(); + let concern: ConversationPlan.ResearchTask = { + kind: "current-cost-concern", + threadId: "thread-1", + observedEventCount: 2, + observedThreadVersion: 1, + options: [ + { id: "r2", labelAtOffer: "Cloudflare R2" }, + { id: "s3", labelAtOffer: "Amazon S3" }, + { id: "b2", labelAtOffer: "Backblaze B2" }, + ], + }; + let across = taskOffer(concern); + expect(across.brief).toBe( + "Investigate current costs across Cloudflare R2, Amazon S3, Backblaze B2. Discussion context: “Compare costs”", + ); + expect(() => assertResearchOfferShape(across)).not.toThrow(); + let focused = taskOffer({ ...concern, focusOptionId: "r2" }, "R2 costs"); + expect(focused.brief).toBe( + "Investigate current costs for Cloudflare R2; consider Amazon S3, Backblaze B2 as context. Discussion context: “R2 costs”", + ); + expect(() => assertResearchOfferShape(focused)).not.toThrow(); + expect(() => assertResearchOfferShape({ ...focused, brief: focused.brief + " changed" })).toThrow( + /brief must match/, + ); + expect(() => assertResearchOfferShape(taskOffer({ ...concern, focusOptionId: "r2" }))).toThrow( + /not grounded/, + ); +}); + +test("research provider codes resolve only unique bounded mentions", () => { + let options = [{ id: "r2", labelAtOffer: "Cloudflare R2" }, { + id: "s3", + labelAtOffer: "Amazon S3", + }]; + expect(researchNamedOptionIds("R2 costs", options)).toEqual(["r2"]); + expect(researchNamedOptionIds("AR2B costs", options)).toEqual([]); + expect(researchNamedOptionIds("R2 and S3 costs", options)).toEqual(["r2", "s3"]); + expect(researchNamedOptionIds("R2", [...options, { id: "other", labelAtOffer: "Other R2" }])) + .toEqual([]); + expect(researchNamedOptionIds("R2", [{ id: "mixed", labelAtOffer: "R2 S3" }])).toEqual([]); +}); + +test("frozen research task fields, option identities, labels, and focus remain bounded", () => { + let offer = taskOffer(comparison()); + for ( + let task of [ + { ...comparison(), threadId: "other" }, + { ...comparison(), observedEventCount: -1 }, + { ...comparison(), focusOptionId: "r2" }, + { ...comparison(), options: [{ id: "r2", labelAtOffer: "R2" }] }, + { + ...comparison(), + options: [{ id: "r2", labelAtOffer: "R2" }, { id: "r2", labelAtOffer: "S3" }], + }, + { + ...comparison(), + options: [{ id: "r2", labelAtOffer: " R2" }, { id: "s3", labelAtOffer: "S3" }], + }, + { + ...comparison(), + options: [{ id: "r2", labelAtOffer: "x".repeat(161) }, { id: "s3", labelAtOffer: "S3" }], + }, + { ...comparison(), extra: true }, + ] + ) expect(() => assertResearchOfferShape({ ...offer, task })).toThrow(); +}); + +test("research status and action actors agree while unknown fields are rejected", () => { + let action = { + id: "action-1", + kind: "research", + actor: { kind: "member", handle: "maggie" }, + principalId: "principal-1", + at: 1, + }; + expect(() => assertResearchOfferShape({ ...legacy(), status: "accepted", action })).not.toThrow(); + expect(() => + assertResearchOfferShape({ + ...legacy(), + status: "dismissed", + action: { ...action, kind: "dismiss" }, + }) + ).not.toThrow(); + for ( + let offer of [ + { ...legacy(), action }, + { ...legacy(), status: "unknown" }, + { ...legacy(), status: "accepted" }, + { ...legacy(), status: "dismissed", action }, + { ...legacy(), status: "accepted", action: { ...action, actor: { kind: "agent" } } }, + { ...legacy(), status: "accepted", action: { ...action, at: -1 } }, + { ...legacy(), source: { ...legacy().source, author: { kind: "agent" } } }, + { ...legacy(), source: { ...legacy().source, role: "support" } }, + { ...legacy(), extra: true }, + ] + ) expect(() => assertResearchOfferShape(offer)).toThrow(); +}); diff --git a/apps/server/src/conversation-plan/research-validation.ts b/apps/server/src/conversation-plan/research-validation.ts new file mode 100644 index 00000000..1ec4fc31 --- /dev/null +++ b/apps/server/src/conversation-plan/research-validation.ts @@ -0,0 +1,174 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { assertSourceShape } from "./sources"; +import { actor, id, knownKeys, record, text, version } from "./validation-fields"; + +const MAX_RESEARCH_OPTION_LABEL = 160; + +/** Both the Chat card and worker receive this exact, code-owned wording. */ +export function renderResearchTask( + task: ConversationPlan.ResearchTask, + source: ConversationPlan.ResearchSource, +): string { + if (task.kind === "current-cost-comparison") { + return `Compare current costs for ${task.options[0].labelAtOffer} and ${ + task.options[1].labelAtOffer + }. Discussion context: “${source.quote}”`; + } + let focus = task.options.find(item => item.id === task.focusOptionId); + let labels = task.options.map(item => item.labelAtOffer); + return focus + ? `Investigate current costs for ${focus.labelAtOffer}; consider ${ + task.options.filter( + item => item.id !== focus.id, + ).map(item => item.labelAtOffer).join(", ") + } as context. Discussion context: “${source.quote}”` + : `Investigate current costs across ${ + labels.join(", ") + }. Discussion context: “${source.quote}”`; +} + +/** A short provider code is usable only when it uniquely resolves within this frozen task. */ +export function researchNamedOptionIds( + quote: string, + options: readonly { id: string; labelAtOffer: string }[], +): string[] { + let codes = options.map( + option => [ + ...new Set( + option.labelAtOffer.match(/(? { + let escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}(?=$|[^\\p{L}\\p{N}])`, "iu") + .test(quote); + }; + return options.filter((option, index) => { + let label = option.labelAtOffer; + let optionCodes = codes[index]!; + if (optionCodes.length > 1) return false; + let code = optionCodes[0]; + return mentions(label) || !!code + && codes.filter(item => item.includes(code)).length === 1 + && mentions(code); + }).map(option => option.id); +} + +export function assertResearchOfferShape( + value: unknown, +): asserts value is ConversationPlan.ResearchOffer { + let offer = record(value); + knownKeys(offer, [ + "id", + "needId", + "contextId", + "source", + "brief", + "threadId", + "task", + "status", + "action", + ]); + id(offer.id); + id(offer.needId); + id(offer.contextId); + let source = record(offer.source); + knownKeys(source, ["messageId", "author", "quote", "start", "end"]); + assertSourceShape({ ...source, role: "support" }); + if ((source.author as { kind: string }).kind !== "member") { + throw new Error("research offer source requires a member message"); + } + actor(source.author); + text(offer.brief, 2048); + if (offer.threadId !== undefined) id(offer.threadId); + if (offer.task === undefined) { + if (offer.brief !== source.quote) throw new Error("research brief must equal source quote"); + } else { + let task = record(offer.task); + knownKeys(task, [ + "kind", + "threadId", + "observedEventCount", + "observedThreadVersion", + "options", + "focusOptionId", + ]); + if (task.kind !== "current-cost-comparison" && task.kind !== "current-cost-concern") { + throw new Error("invalid research task kind"); + } + id(task.threadId); + version(task.observedEventCount); + version(task.observedThreadVersion); + if (offer.threadId !== task.threadId) throw new Error("research task thread disagrees"); + let count = task.kind === "current-cost-comparison" ? 2 : 3; + if ( + !Array.isArray(task.options) + || task.options.length < count + || task.options.length > (task.kind === "current-cost-comparison" ? 2 : 4) + ) { + throw new Error( + task.kind === "current-cost-comparison" + ? "research task requires two options" + : "research concern requires three or four options", + ); + } + let optionIds = new Set(); + for (let value of task.options) { + let option = record(value); + knownKeys(option, ["id", "labelAtOffer"]); + id(option.id); + text(option.labelAtOffer, MAX_RESEARCH_OPTION_LABEL); + if ( + option.labelAtOffer !== (option.labelAtOffer as string).trim() + || /[\r\n]/.test(option.labelAtOffer as string) + ) { + throw new Error("invalid research option label"); + } + optionIds.add(option.id as string); + } + if (optionIds.size !== task.options.length) throw new Error("duplicate research task option"); + if (task.kind === "current-cost-comparison") { + if (task.focusOptionId !== undefined) throw new Error("invalid research task focus"); + } else { + if (task.focusOptionId !== undefined) { + id(task.focusOptionId); + if (!optionIds.has(task.focusOptionId as string)) { + throw new Error("invalid research task focus"); + } + } + let named = researchNamedOptionIds( + source.quote as string, + task.options as Array<{ id: string; labelAtOffer: string }>, + ); + if (named.length > 1 || task.focusOptionId !== named[0]) { + throw new Error("research task focus is not grounded in its source"); + } + } + if ( + offer.brief !== renderResearchTask( + task as ConversationPlan.ResearchTask, + source as ConversationPlan.ResearchSource, + ) + ) throw new Error("research brief must match task and source"); + } + if (!["offered", "dismissed", "accepted"].includes(offer.status as string)) { + throw new Error("invalid research offer status"); + } + if (offer.status === "offered") { + if (offer.action !== undefined) throw new Error("unacted research offer has an action"); + return; + } + let action = record(offer.action); + knownKeys(action, ["id", "kind", "actor", "principalId", "at"]); + id(action.id); + id(action.principalId); + if (action.kind !== (offer.status === "accepted" ? "research" : "dismiss")) { + throw new Error("research offer action disagrees with status"); + } + actor(action.actor); + if ((action.actor as { kind: string }).kind !== "member") { + throw new Error("research offer action requires a member"); + } + version(action.at); +} diff --git a/apps/server/src/conversation-plan/review-analysis.test.ts b/apps/server/src/conversation-plan/review-analysis.test.ts new file mode 100644 index 00000000..3781b715 --- /dev/null +++ b/apps/server/src/conversation-plan/review-analysis.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { completeAnalysis, enqueue, initialState, retryMessage } from "./domain"; +import { message } from "./review.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. + +describe("reviewed conversation-plan analysis invariants", () => { + test("failed queued analysis needs an explicit retry before completion", () => { + let entry = message("m1", "A question?"); + let state = enqueue(initialState(), entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "failed", + passes: [], + error: "temporary failure", + }); + expect(() => + completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }) + ).toThrow(/retry|pending|queued/i); + state = retryMessage(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + expect(state.queue).toEqual([]); + }); + + test("keeps at least 26 message diagnostics while remaining bounded", () => { + let state = initialState(); + for (let index = 0; index < 26; index++) { + let entry = message(`analysis-${index}`, "No planning change"); + state = enqueue(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + } + expect(state.analysis).toHaveLength(26); + expect(state.analysis[0].messageId).toBe("analysis-0"); + for (let index = 26; index < 66; index++) { + let entry = message(`analysis-${index}`, "No planning change"); + state = enqueue(state, entry.id); + state = completeAnalysis(state, entry.id, [], entry, { + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + }); + } + expect(state.analysis).toHaveLength(64); + expect(state.analysis[0].messageId).toBe("analysis-2"); + }); +}); diff --git a/apps/server/src/conversation-plan/review.test-fixtures.ts b/apps/server/src/conversation-plan/review.test-fixtures.ts new file mode 100644 index 00000000..2e210e07 --- /dev/null +++ b/apps/server/src/conversation-plan/review.test-fixtures.ts @@ -0,0 +1,100 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference } from "./domain"; +import { applyEvent } from "./events"; + +// Exact actor/helper declarations: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. +export let alice = { kind: "member", handle: "alice" } as const; +export let bob = { kind: "member", handle: "bob" } as const; + +export function message(id: string, text: string, author: Chat.Author = alice): Chat.Entry { + return { id, text, author, ts: 1 }; +} + +export function source( + entry: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote: entry.text, + start: 0, + end: entry.text.length, + role, + }; +} + +export function open( + state: ConversationPlan.State, + threadId = "t1", + entry = message(`question-${threadId}`, `Should we use ${threadId}?`), +): ConversationPlan.State { + return applyInference(state, { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }, entry); +} + +export function addOption( + state: ConversationPlan.State, + threadId: string, + optionId: string, +): ConversationPlan.State { + let entry = message(`option-${optionId}`, `Option ${optionId}`); + let thread = state.threads.find((item) => item.id === threadId)!; + return applyInference(state, { + id: `add-${optionId}`, + type: "option.added", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: source(entry, "option"), + contribution: { id: optionId, text: entry.text, authoring: "quoted" }, + }, entry); +} + +export function decide(state: ConversationPlan.State, optionId?: string): ConversationPlan.State { + return applyEvent(state, { + id: "resolve-event", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 3, + text: "Do that", + optionId, + explicit: true, + }); +} + +export function correct( + state: ConversationPlan.State, + actionId: string, + threadId: string, + change: ConversationPlan.CorrectionChange, + author: typeof alice | typeof bob = bob, +): ConversationPlan.State { + let thread = state.threads.find((item) => item.id === threadId)!; + return applyCorrection( + state, + { + actionId, + threadId, + expectedVersion: thread.version, + change, + }, + author, + 4, + ); +} diff --git a/apps/server/src/conversation-plan/review.test.ts b/apps/server/src/conversation-plan/review.test.ts new file mode 100644 index 00000000..c64e4748 --- /dev/null +++ b/apps/server/src/conversation-plan/review.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference, initialState, restoreState } from "./domain"; +import { + addOption, + alice, + bob, + correct, + decide, + message, + open, + source, +} from "./review.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/review.test.ts. + +describe("reviewed conversation-plan invariants", () => { + test("a question edit attributes the correcting member, distinct from its source speaker", () => { + let state = open(initialState()); + state = correct(state, "edit-question", "t1", { + kind: "edit", + field: "question", + text: "Should the outline be optional?", + }); + expect(state.threads[0].questionEditedBy).toBe("bob"); + expect(state.threads[0].questionSources[0].author).toEqual(alice); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads[0].questionEditedBy).toBe("bob"); + }); + + test("reopened status needs a current decision and retains its history", () => { + let state = open(initialState()); + expect(() => + correct(state, "reopen-too-early", "t1", { + kind: "set-status", + status: "reopened", + }) + ).toThrow(/decid/i); + state = decide(state); + state = correct(state, "reopen-now", "t1", { kind: "set-status", status: "reopened" }); + expect(state.threads[0].status).toBe("reopened"); + expect(state.threads[0].decision).toBeUndefined(); + expect(state.threads[0].decisionHistory).toHaveLength(1); + }); + + test("an option with a historical decision link cannot move after reopening", () => { + let state = open(initialState()); + state = addOption(state, "t1", "o1"); + state = decide(state, "o1"); + state = correct(state, "reopen", "t1", { kind: "set-status", status: "reopened" }); + state = open(state, "t2"); + expect(() => + correct(state, "move-o1", "t1", { + kind: "move", + contributionId: "o1", + targetThreadId: "t2", + targetVersion: 1, + }) + ).toThrow(/referenced/i); + }); + + test("moving into a full destination rejects the correction", () => { + let state = open(initialState()); + state = addOption(state, "t1", "o1"); + state = open(state, "t2"); + for (let index = 0; index < 64; index++) { + state = addOption(state, "t2", `target-${index}`); + } + expect(() => + correct(state, "overflow", "t1", { + kind: "move", + contributionId: "o1", + targetThreadId: "t2", + targetVersion: state.threads[1].version, + }) + ).toThrow(/limit|full/i); + expect(state.threads[1].contributions).toHaveLength(64); + }); + + test("unknown fields cannot carry unbounded data in events or debug snapshots", () => { + let entry = message("extra", "A question?"); + let event: ConversationPlan.Event = { + id: "extra-event", + type: "thread.opened", + threadId: "t1", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }; + let forged = { ...event, unbounded: "x".repeat(100_000) } as ConversationPlan.Event; + expect(() => applyInference(initialState(), forged, entry)).toThrow(/unknown|unexpected/i); + let snapshot = initialState() as ConversationPlan.State & { unbounded?: string }; + snapshot.analysis.push({ + messageId: "m1", + questionSetVersion: "v1", + modelVersion: "model", + status: "unlinked", + passes: [], + eventIds: [], + unbounded: "x".repeat(100_000), + } as ConversationPlan.AnalysisRecord); + expect(() => restoreState(snapshot)).toThrow(/unknown|unexpected/i); + }); + + test("question edits reject an unrelated unbounded contribution ID", () => { + let state = open(initialState()); + let failure: unknown; + try { + correct(state, "bad-question-edit", "t1", { + kind: "edit", + field: "question", + text: "Revised question", + contributionId: "x".repeat(1_000_000), + } as unknown as ConversationPlan.CorrectionChange); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/invalid|unknown|contribution/i); + }); + + test("decision edits reject an unrelated contribution ID on ingress and restore", () => { + let state = decide(open(initialState())); + let failure: unknown; + try { + correct(state, "bad-decision-edit", "t1", { + kind: "edit", + field: "decision", + text: "Revised decision", + contributionId: "o1", + } as unknown as ConversationPlan.CorrectionChange); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/invalid|unknown|contribution/i); + let valid = correct(state, "good-decision-edit", "t1", { + kind: "edit", + field: "decision", + text: "Revised decision", + }); + let saved = JSON.parse(JSON.stringify(valid)); + saved.events.at(-1).change.contributionId = "o1"; + expect(() => restoreState(saved)).toThrow(); + }); + + test("same human action ID is a noop only for an identical action", () => { + let state = open(initialState()); + let action: ConversationPlan.CorrectionAction = { + actionId: "edit-1", + threadId: "t1", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "A revised question" }, + }; + let changed = applyCorrection(state, action, bob, 4); + expect(applyCorrection(changed, action, bob, 5)).toBe(changed); + let persisted = restoreState(JSON.parse(JSON.stringify(changed))); + expect(applyCorrection(persisted, action, bob, 5)).toBe(persisted); + expect(() => + applyCorrection( + changed, + { + ...action, + change: { kind: "edit", field: "question", text: "A different question" }, + }, + bob, + 5, + ) + ).toThrow(/collision|different/i); + expect(() => applyCorrection(changed, { ...action, expectedVersion: 2 }, bob, 5)) + .toThrow(/collision|different/i); + }); +}); diff --git a/apps/server/src/conversation-plan/runtime-acceptance.test.ts b/apps/server/src/conversation-plan/runtime-acceptance.test.ts new file mode 100644 index 00000000..793e142f --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-acceptance.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +test("runtime withholds chat acknowledgement and publication until the transcript and queue commit", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let started = false; + let release!: () => void; + let gate = new Promise(resolve => release = resolve); + let original = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + started = true; + await gate; + return original(input); + }; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + interpret: async () => ({ + events: [], + analysis: { + status: "unlinked", + questionSetVersion: "test", + modelVersion: "test", + passes: [], + }, + }), + }); + let replies: string[] = []; + let socket = { + data: { handle: "test", principalId: "U_test" }, + send(value: string) { + replies.push(value); + }, + } as unknown as Socket; + let send: Promise | undefined; + try { + await runtime.attach(room, plan, false); + let context = runtime.bind( + { + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: { agent: false } as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room, + ); + send = Chat.send(context, socket, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "room", + text: "Discuss authentication", + ts: 0, + }); + await until(() => started); + expect(replies).toEqual([]); + expect(opened.broadcasts).toEqual([]); + let before = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(before.sidecar ?? before.snapshot!.sidecar).toMatchObject({ transcript: [] }); + release(); + await send; + expect(replies.map(value => JSON.parse(value).kind)).toContain("chat:send"); + let after = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(after.sidecar ?? after.snapshot!.sidecar).toMatchObject({ + transcript: [{ text: "Discuss authentication" }], + conversationPlan: { queue: [{ status: "pending" }] }, + }); + } finally { + release(); + await send; + let stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-card.test.ts b/apps/server/src/conversation-plan/runtime-card.test.ts new file mode 100644 index 00000000..111ab312 --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-card.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +// Offline synthetic model answers exercise the production interpreter and card consumers. +test("runtime saves a room message, projects a durable decision card, and reopens without duplicating it", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let errors: unknown[] = []; + let config = { agent: false, conversationPlan: true }; + let runtime = createConversationRuntime({ + config, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + interpret: input => + interpretMessage({ + ...input, + ask: async request => + mockResult(request.questions, { + new_question: 0.95, + enough_purpose: 0.95, + act: "question", + thread_target: "new", + c0_role: "question", + c0_thread: "new", + c0_duplicate: 0.05, + }), + }), + }); + let context = runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: config as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room); + let socket = { data: { handle: "test", principalId: "U_test" }, send() {} } as unknown as Socket; + let stopped: Promise | undefined; + try { + await runtime.attach(room, plan, false); + runtime.bind(context); + await Chat.send(context, socket, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "room", + text: "Which authentication system should we use?", + ts: 0, + }); + await until(() => + plan.conversationPlan.threads[0]?.questionnaireId !== undefined + && plan.conversationPlanPendingEffects.length === 0 + ); + await runtime.jobs(plan)?.idle(); + let thread = plan.conversationPlan.threads[0]!; + let card = plan.records.get(thread.questionnaireId!)!; + expect(card.origin).toBe("conversation"); + expect(card.threadId).toBe(thread.id); + expect(card.status).toBe("open"); + expect(Plan.source(plan)).toContain(thread.questionnaireId!); + let loaded = (await opened.storage.collaboration.load(plan.id, opened.now))!; + let sidecar = (loaded.sidecar ?? loaded.snapshot!.sidecar) as unknown as { + transcript: typeof plan.chat.entries; + conversationPlan: typeof plan.conversationPlan; + conversationPlanEffects: string[]; + }; + expect(sidecar.transcript[0]?.text).toBe("Which authentication system should we use?"); + expect(sidecar.conversationPlan.threads[0]?.questionnaireId).toBe(card.id); + expect(sidecar.conversationPlanEffects).toContain(`insert:${thread.id}`); + expect(sidecar.conversationPlan.analysis[0]?.status).toBe("applied"); + stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + room.plan = plan; + await runtime.attach(room, plan, false); + await Bun.sleep(10); + expect(plan.records.size).toBe(1); + expect(plan.conversationPlan.threads[0]?.questionnaireId).toBe(card.id); + expect(plan.conversationPlanPendingEffects).toEqual([]); + expect(errors).toEqual([]); + } finally { + stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + } +}); + +test("disabled and archived runtime cannot accept a queued conversation mutation", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let disabled = createConversationRuntime({ + config: { agent: false }, + server: () => opened.server, + unavailable: () => false, + }); + let archived = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + }); + try { + await disabled.attach(room, plan, false); + expect(disabled.processor(plan)).toBeUndefined(); + await archived.attach(room, plan, true); + expect(archived.jobs(plan)).toBeUndefined(); + await expect( + archived.processor(plan)!.accept({ + id: "blocked", + author: { kind: "member", handle: "test" }, + text: "Which approach?", + ts: 0, + }), + ).rejects.toThrow("unavailable"); + expect(plan.chat.entries).toEqual([]); + expect(plan.conversationPlan.queue).toEqual([]); + } finally { + await archived.stop(plan); + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-lifecycle.test.ts b/apps/server/src/conversation-plan/runtime-lifecycle.test.ts new file mode 100644 index 00000000..c274e2ff --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-lifecycle.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { applyInference, initialState } from "./domain"; +import { createConversationRuntime } from "./runtime"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; + +/** Two replay-valid openings exercise real card consumers, with the first card commit held. */ +async function heldInsert() { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + plan.conversationPlan = initialState(); + for (let index = 1; index <= 2; index++) { + let entry = { + id: `m${index}`, + author: { kind: "member" as const, handle: "test" }, + text: `Which approach ${index}?`, + ts: index, + }; + plan.chat.entries.push(entry); + plan.conversationPlan = applyInference(plan.conversationPlan, { + id: `open${index}`, + type: "thread.opened", + threadId: `t${index}`, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: index, + question: entry.text, + source: { + messageId: entry.id, + author: entry.author, + quote: entry.text, + start: 0, + end: entry.text.length, + role: "question", + }, + }, entry); + } + plan.conversationPlanPendingEffects = plan.conversationPlan.threads.map((thread, index) => ({ + key: `insert:${thread.id}`, + kind: "insert-card", + threadId: thread.id, + header: "Decision", + question: thread.question, + options: [], + trigger: `m${index + 1}`, + })); + await Plan.persist(plan); + let entered = false; + let closed = false; + let commitsAfterClose = 0; + let publicationsAfterClose = 0; + let release!: () => void; + let gate = new Promise(resolve => release = resolve); + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let questions = (input.sidecar as { questions?: unknown[] } | undefined)?.questions; + if (!entered && questions?.length === 1) { + entered = true; + await gate; + } + if (closed) commitsAfterClose++; + return commit(input); + }; + let publish = opened.server.publish.bind(opened.server); + opened.server.publish = (...args) => { + if (closed) publicationsAfterClose++; + return publish(...args); + }; + let errors: unknown[] = []; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + }); + await runtime.attach(room, plan, false); + await until(() => entered); + return { + ...opened, + room, + runtime, + release, + errors, + closed: () => closed = true, + late: () => ({ commits: commitsAfterClose, publications: publicationsAfterClose }), + }; +} + +test("runtime stop drains a held real card insertion before document close", async () => { + let fixture = await heldInsert(); + let stopped = false; + let stopping = fixture.runtime.stop(fixture.plan).then(() => stopped = true); + try { + await Bun.sleep(10); + expect(stopped).toBe(false); + fixture.release(); + await stopping; + await Plan.close(fixture.plan); + fixture.closed(); + await Bun.sleep(10); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.late()).toEqual({ commits: 0, publications: 0 }); + let restored = await Plan.open(fixture.channel.id, fixture.backend, fixture.server); + try { + expect(restored.records.size).toBe(2); + expect(restored.conversationPlanPendingEffects).toHaveLength(2); + } finally { + await Plan.close(restored); + } + } finally { + fixture.release(); + await stopping; + if (!fixture.plan.persistence.closing) await Plan.close(fixture.plan); + } +}); + +test("replacement attachment waits for the old real effect drain and recovers without duplicate cards", async () => { + let fixture = await heldInsert(); + let previous = fixture.runtime.processor(fixture.plan); + let attached = false; + let attaching = Promise.resolve(fixture.runtime.attach(fixture.room, fixture.plan, false)) + .then(() => attached = true); + try { + await Bun.sleep(10); + expect(attached).toBe(false); + fixture.release(); + await attaching; + expect(fixture.runtime.processor(fixture.plan)).not.toBe(previous); + await until(() => fixture.plan.conversationPlanPendingEffects.length === 0); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.plan.conversationPlan.threads.every(thread => !!thread.questionnaireId)).toBe( + true, + ); + expect(fixture.broadcasts.filter(frame => frame.kind === "question:asked")).toHaveLength(2); + expect(fixture.errors).toEqual([]); + } finally { + fixture.release(); + await attaching; + await fixture.runtime.stop(fixture.plan); + await Plan.close(fixture.plan); + } +}); + +test("overlapping stops and replacement attachment share the held real effect drain", async () => { + let fixture = await heldInsert(); + let previous = fixture.runtime.processor(fixture.plan); + let firstDone = false; + let secondDone = false; + let attached = false; + let first = fixture.runtime.stop(fixture.plan); + let second = fixture.runtime.stop(fixture.plan); + let firstSettled = first.then(() => firstDone = true); + let secondSettled = second.then(() => secondDone = true); + let attaching = fixture.runtime.attach(fixture.room, fixture.plan, false) + .then(() => attached = true); + try { + await Bun.sleep(10); + expect(firstDone).toBe(false); + expect(secondDone).toBe(false); + expect(attached).toBe(false); + expect(second).toBe(first); + fixture.release(); + await Promise.all([firstSettled, secondSettled, attaching]); + expect(fixture.runtime.processor(fixture.plan)).not.toBe(previous); + await until(() => fixture.plan.conversationPlanPendingEffects.length === 0); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.broadcasts.filter(frame => frame.kind === "question:asked")).toHaveLength(2); + expect(fixture.errors).toEqual([]); + } finally { + fixture.release(); + await Promise.all([firstSettled, secondSettled, attaching]); + await fixture.runtime.stop(fixture.plan); + await Plan.close(fixture.plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-save.test.ts b/apps/server/src/conversation-plan/runtime-save.test.ts new file mode 100644 index 00000000..bbda95ac --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-save.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +function noop(): void {} + +test("a live conversation suggests a linked human option, and human Save commits before acknowledgement and survives reopening", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let errors: unknown[] = []; + let replies: Array> = []; + let socket = { + data: { handle: "ana", principalId: "U_ana", client: "client", room: plan.id }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + let optionId = ""; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + interpret: input => + interpretMessage({ + ...input, + ask: async request => + mockResult( + request.questions, + optionId + ? { + act: "commitment", + explicit_resolution: 0.98, + thread_target: input.state.threads[0]!.id, + c0_role: "resolution", + c0_thread: input.state.threads[0]!.id, + c0_explicit_resolution: 0.98, + c0_chosen_option: optionId, + } + : { + new_question: 0.95, + enough_purpose: 0.95, + act: "question", + thread_target: "new", + c0_role: "question", + c0_thread: "new", + c0_duplicate: 0.05, + }, + ), + }), + }); + let release = noop; + let submitting: Promise | undefined; + try { + await runtime.attach(room, plan, false); + let context = runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: { agent: false } as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room); + await Chat.send(context, socket, { + kind: "chat:send", + rid: "opening", + requestId: crypto.randomUUID(), + to: "room", + text: "Which authentication system should we use?", + ts: 0, + }); + await until(() => + !!plan.conversationPlan.threads[0]?.questionnaireId + && plan.conversationPlanPendingEffects.length === 0 + ); + let thread = plan.conversationPlan.threads[0]!; + let id = thread.questionnaireId!; + await Questions.addOption(plan, opened.server, plan.id, socket, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + rid: "add", + ts: 0, + id, + label: "Passkeys", + }); + let added = replies.find(frame => frame.rid === "add")!; + expect(added.ok).toBe(true); + optionId = added.option.id; + await Chat.send(context, socket, { + kind: "chat:send", + rid: "choice", + requestId: crypto.randomUUID(), + to: "room", + text: "Let's go with Passkeys.", + ts: 0, + }); + await until(() => + Store.get(plan.questions, id)?.suggested?.optionId === optionId + && plan.conversationPlanPendingEffects.length === 0 + ); + expect(plan.records.get(id)?.status).toBe("open"); + expect(plan.conversationPlan.threads[0]?.decision).toBeUndefined(); + expect(plan.chat.entries.some(entry => entry.decision?.kind === "prompt")).toBe(true); + let settled = false; + let entered = false; + let gate = new Promise(resolve => release = resolve); + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let records = (input.sidecar as { questions?: { status: string }[] })?.questions; + if (!entered && records?.some(record => record.status === "answered")) { + entered = true; + await gate; + } + return commit(input); + }; + let revision = Store.get(plan.questions, id)!.revision; + submitting = Questions.submit(plan, opened.server, plan.id, socket, { + kind: "question:submit", + rid: "save", + ts: 0, + id, + revision, + suggestedOptionId: optionId, + }).then(() => { + settled = true; + }); + await until(() => entered); + expect(settled).toBe(false); + expect(replies.some(frame => frame.rid === "save")).toBe(false); + expect(plan.records.get(id)?.status).toBe("open"); + let before = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(before.sidecar ?? before.snapshot!.sidecar).toMatchObject({ + questions: [{ id, status: "open" }], + }); + release(); + await submitting; + expect(replies.find(frame => frame.rid === "save")?.ok).toBe(true); + await until(() => + plan.conversationPlan.threads[0]?.decision?.actor.kind === "member" + && plan.pendingCardActions.length === 0 + && plan.conversationPlanPendingEffects.length === 0 + ); + expect(plan.records.get(id)).toMatchObject({ + status: "answered", + owner: "ana", + choices: [optionId], + }); + expect(plan.conversationPlan.threads[0]?.decision).toMatchObject({ + actor: { kind: "member", handle: "ana" }, + text: "Passkeys", + }); + expect(errors).toEqual([]); + await runtime.stop(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + room.plan = plan; + await runtime.attach(room, plan, false); + await runtime.processor(plan)?.idle(); + expect(plan.records.get(id)).toMatchObject({ + status: "answered", + owner: "ana", + choices: [optionId], + }); + expect(plan.conversationPlan.threads[0]?.decision).toMatchObject({ + actor: { kind: "member", handle: "ana" }, + text: "Passkeys", + }); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.records.size).toBe(1); + } finally { + release(); + await submitting; + await runtime.stop(plan); + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime.ts b/apps/server/src/conversation-plan/runtime.ts new file mode 100644 index 00000000..63c120b0 --- /dev/null +++ b/apps/server/src/conversation-plan/runtime.ts @@ -0,0 +1,187 @@ +/** Connects durable conversation processing to the current authenticated Chat runtime. */ +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Room from "../plan/room"; +import { broadcast } from "../wire"; +import { cardEffects, mirrorCard, wakeCardMirror } from "./cards"; +import { createJobContexts } from "./job-context"; +import { PROMPT_FOR } from "./job-prompts"; +import { createPlannerJobs, interruptPlannerJobs } from "./planner-jobs"; +import { createProcessor, type Dependencies, type Processor } from "./service"; +import { interpretMessage } from "./interpret"; +import { askJev } from "./jev"; +import type { Config } from "../config"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type * as Rooms from "../rooms"; + +type RuntimeDeps = { + config: Pick< + Config, + "agent" | "conversationPlan" | "conversationPlanModel" | "conversationPlanTimeoutMs" + >; + server: () => Server; + unavailable: (id: string) => boolean; + interpret?: Dependencies["interpret"]; + onError?: (error: unknown) => void; +}; + +export function createConversationRuntime(deps: RuntimeDeps) { + let processors = new WeakMap(); + let jobs = new WeakMap>(); + let listeners = new WeakMap void>(); + let contexts = createJobContexts(); + let mirrors = new WeakMap>(); + let stopping = new WeakMap>(); + let report = deps.onError ?? ((error: unknown) => console.error("[conversation-plan]", error)); + + function stop(opened: Plan.Plan): Promise { + let existing = stopping.get(opened); + if (existing) return existing; + let processor = processors.get(opened); + processor?.stop(); + processors.delete(opened); + let coordinator = jobs.get(opened); + coordinator?.stop(); + jobs.delete(opened); + listeners.get(opened)?.(); + listeners.delete(opened); + contexts.clear(opened); + let mirroring = mirrors.get(opened); + mirrors.delete(opened); + let draining = Promise.all([processor?.idle(), coordinator?.idle(), mirroring]) + .then(() => {}) + .finally(() => { + if (stopping.get(opened) === draining) stopping.delete(opened); + }); + stopping.set(opened, draining); + return draining; + } + + function wake(opened: Plan.Plan): void { + let processor = processors.get(opened); + if (!processor) return; + if (!deps.unavailable(opened.id)) jobs.get(opened)?.wake(); + mirrors.set(opened, wakeCardMirror(opened, processor).catch(report)); + } + + async function attach(room: Rooms.Room, opened: Plan.Plan, archived: boolean): Promise { + if (!deps.config.conversationPlan) return; + await stop(opened); + let server = deps.server(); + let active = () => + room.plan === opened && !room.closing && !archived + && !deps.unavailable(room.id); + let announcer: Chat.Announcer = { chat: opened.chat, plan: opened, server, room: room.id }; + let processor: Processor; + let coordinator = archived || deps.unavailable(room.id) ? undefined : createPlannerJobs({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened), + runner: async (job, prompt, signal) => { + if (!active()) return { status: "skipped", reason: "The document is unavailable." }; + if (!deps.config.agent) { + return { status: "skipped", reason: "The Planner is off (AGENT=off)." }; + } + let claim = contexts.claim(opened, job); + return claim + ? Chat.job(claim.context, job, prompt, claim.claimantSessionId, signal) + : { + status: "skipped", + reason: "No signed-in member is available to supply the Planner credential.", + }; + }, + prompt: job => PROMPT_FOR[job.kind]?.(opened, job), + headingAllowed: () => Room.headingAllowed(opened.document), + publishJobs: current => + broadcast(server, room.id, { kind: "conversation-plan:jobs", ts: 0, jobs: current }), + publishMeta: id => Questions.announce(opened, server, room.id, id), + activity: async (text, questionnaireId, label) => { + await Chat.noticeExclusive(announcer, { + text, + decision: { questionnaireId, kind: "activity", ...(label ? { label } : {}) }, + }); + }, + onCapacityAvailable: () => processor.wake(), + onError: report, + }); + if (coordinator) jobs.set(opened, coordinator); + processor = createProcessor({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened), + publish: state => + broadcast(server, room.id, { kind: "conversation-plan:changed", ts: 0, state }), + active, + interpret: deps.interpret ?? ((input, signal) => + interpretMessage({ + ...input, + ask: request => + askJev(request, { + model: deps.config.conversationPlanModel, + timeoutMs: deps.config.conversationPlanTimeoutMs, + signal, + }), + })), + onError: report, + }); + processors.set(opened, processor); + processor.setEffects( + cardEffects( + opened, + server, + room.id, + processor, + coordinator + ? (intent, key) => coordinator.enqueue(intent, key) + : undefined, + announcer, + ), + ); + let recover = () => { + processor.wake(); + mirrors.set(opened, mirrorCard(opened, processor).catch(report)); + }; + listeners.set(opened, Questions.listen(opened, recover)); + recover(); + } + + function bind(context: Chat.Room): Chat.Room { + let opened = context.plan; + let processor = processors.get(opened); + if (processor) { + context.commitRoomMessage = entry => + contexts.accept(opened, entry.id, context, () => processor.accept(entry)); + context.roomMessagePublished = () => { + processor.afterMessage(); + wake(opened); + }; + } + return context; + } + + function interrupt(opened: Plan.Plan): Promise { + let server = deps.server(); + return interruptPlannerJobs({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened, true), + publishJobs: current => + broadcast(server, opened.id, { kind: "conversation-plan:jobs", ts: 0, jobs: current }), + publishMeta: id => Questions.announce(opened, server, opened.id, id), + onError: report, + }); + } + + return { + attach, + bind, + wake, + stop, + interrupt, + contexts, + processor: (opened: Plan.Plan) => processors.get(opened), + jobs: (opened: Plan.Plan) => jobs.get(opened), + }; +} 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/scoped-save-validation.test.ts b/apps/server/src/conversation-plan/scoped-save-validation.test.ts new file mode 100644 index 00000000..0fd3356e --- /dev/null +++ b/apps/server/src/conversation-plan/scoped-save-validation.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +type Event = ConversationPlan.Event; +type Saved = Extract; + +function source(handle: string, messageId: string, quote: string): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle }, + quote, + start: 0, + end: quote.length, + role: "support", + }; +} + +function fixture() { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let state = applyEvent(initialState(), { + id: "open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: { ...source("Mei", "question", "Which editor?"), role: "question" }, + question: "Which editor?", + }); + state = applyEvent(state, { + id: "link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: cardId, + }); + let proposal: Extract = { + id: "proposal", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source("Mei", "mei", "I'd pick Lexical for the spike;"), + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyEvent(state, proposal); + let agreements: Array> = []; + for (let [handle, at] of [["Rob", 4], ["Jo", 5]] as const) { + let agreement: Extract = { + id: `agreement-${handle}`, + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at, + source: source(handle, `message-${handle}`, "yep, Lexical for the spike."), + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyEvent(state, agreement); + agreements.push(agreement); + } + let saved: Saved = { + id: "save", + type: "scoped-choice.saved", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Rob" }, + at: 6, + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + supportEventIds: [proposal.id, ...agreements.map(item => item.id)], + sources: [proposal.source, ...agreements.map(item => item.source)], + expectedGeneration: 0, + }; + return { state, proposal, agreements, saved }; +} + +test("multi-support scoped Save replays exact unique source IDs in event order", () => { + let { state, proposal, agreements, saved } = fixture(); + let accepted = applyEvent(state, saved); + expect(restoreState(accepted).events.at(-1)).toEqual(saved); + let invalid: Saved[] = [ + { ...saved, supportEventIds: [proposal.id, agreements[0]!.id, agreements[0]!.id] }, + { ...saved, supportEventIds: [agreements[0]!.id, proposal.id, agreements[1]!.id] }, + { ...saved, supportEventIds: [proposal.id, "other", agreements[1]!.id] }, + { ...saved, sources: [agreements[0]!.source, proposal.source, agreements[1]!.source] }, + { ...saved, sources: [proposal.source, agreements[0]!.source, agreements[0]!.source] }, + ]; + for (let item of invalid) expect(() => applyEvent(state, item)).toThrow(); + let quote = "I withdraw my support for Lexical."; + let withdrawn = applyEvent(state, { + id: "rob-withdraws", + type: "stance.changed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: { ...source("Rob", "rob-withdrawal", quote), role: "withdrawal" }, + scopedProposalId: proposal.id, + position: "neutral", + }); + expect(() => + applyEvent(withdrawn, { + ...saved, + observedThreadVersion: withdrawn.threads[0]!.version, + }) + ).toThrow(); +}); + +test("legacy-shaped Save cannot omit a currently active supporter", () => { + let { state, proposal, agreements, saved } = fixture(); + let stale: Saved = { + ...saved, + agreementId: agreements[0]!.id, + sources: [proposal.source, agreements[0]!.source], + }; + delete stale.supportEventIds; + expect(() => applyEvent(state, stale)).toThrow(); +}); + +test("legacy-shaped Save cannot include a withdrawn supporter", () => { + let { state, proposal, agreements, saved } = fixture(); + let quote = "I withdraw my support for Lexical."; + let withdrawn = applyEvent(state, { + id: "rob-withdraws-before-legacy-save", + type: "stance.changed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 6, + source: { ...source("Rob", "rob-withdrawal", quote), role: "withdrawal" }, + scopedProposalId: proposal.id, + position: "neutral", + }); + let stale: Saved = { + ...saved, + observedThreadVersion: withdrawn.threads[0]!.version, + agreementId: agreements[0]!.id, + sources: [proposal.source, agreements[0]!.source], + }; + delete stale.supportEventIds; + expect(() => applyEvent(withdrawn, stale)).toThrow(); +}); + +test("historical proposal-only and proposal-plus-agreement Saves still restore", () => { + let { state, proposal, agreements, saved } = fixture(); + for (let count of [3, 4]) { + let historical = initialState(); + for (let event of state.events.slice(0, count)) historical = applyEvent(historical, event); + let legacy: Saved = { + ...saved, + observedThreadVersion: historical.threads[0]!.version, + ...(count === 4 ? { agreementId: agreements[0]!.id } : {}), + sources: count === 4 + ? [proposal.source, agreements[0]!.source] + : [proposal.source], + }; + delete legacy.supportEventIds; + let accepted = applyEvent(historical, legacy); + expect(restoreState(accepted).events.at(-1)).toEqual(legacy); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-environment.test.ts b/apps/server/src/conversation-plan/scripted-environment.test.ts new file mode 100644 index 00000000..6d4dea48 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-environment.test.ts @@ -0,0 +1,170 @@ +import { expect, it } from "bun:test"; +import { join } from "node:path"; + +import { + requireScriptedServer, + SCRIPTED_HARNESS, +} from "../../../../e2e/harness/scripted-environment"; + +let root = "/offline/chopin"; +let approved: Record = { + E2E_CONVERSATION_PLAN: "1", + SERVER_HOST: "127.0.0.1", + PORT: "8788", + APP_ORIGIN: "http://127.0.0.1:8788", + AGENT: "on", + CONVERSATION_PLAN: "on", + HARNESS: SCRIPTED_HARNESS, + HARNESS_AUTH: "direct", + JEV_API_KEY: "e2e-jev-only", + JEV_MODEL: "jev-e2e", + TYPESAFE_API_KEY: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + NO_PROXY: "*", + no_proxy: "*", + E2E_PLANNER_JOBS_DIR: join(root, "e2e", "test-results", "planner-jobs"), + E2E_JEV_CONTROL_DIR: join(root, "e2e", "test-results", "jev-control"), + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://test:fake-password@127.0.0.1:5433/chopin?sslmode=disable", + SESSION_ENCRYPTION_KEY: "a".repeat(64), +}; + +it("accepts the explicitly selected local test environment without changing its input", () => { + let env = Object.freeze({ ...approved }); + expect(() => requireScriptedServer(env, root)).not.toThrow(); + expect(env).toEqual(approved); +}); + +let rejected: Array<[string, string | undefined]> = [ + ["E2E_CONVERSATION_PLAN", undefined], + ["E2E_CONVERSATION_PLAN", "true"], + ["SERVER_HOST", "0.0.0.0"], + ["SERVER_HOST", "localhost"], + ["PORT", "8792"], + ["PORT", "08788"], + ["APP_ORIGIN", "http://localhost:8788"], + ["APP_ORIGIN", "http://127.0.0.1:8788/"], + ["AGENT", "off"], + ["CONVERSATION_PLAN", "off"], + ["HARNESS", "e2e-fake"], + ["HARNESS_AUTH", "auto"], + ["JEV_API_KEY", "real-provider-secret"], + ["JEV_MODEL", "real-model"], + ["TYPESAFE_API_KEY", "real-provider-secret"], + ["TYPESAFE_API_KEY", undefined], + ["E2E_PLANNER_JOBS_DIR", "/tmp/planner-jobs"], + ["E2E_PLANNER_JOBS_DIR", `${approved.E2E_PLANNER_JOBS_DIR}/`], + ["E2E_JEV_CONTROL_DIR", "/tmp/jev-control"], + ["STORAGE_DRIVER", "memory"], + ["SESSION_ENCRYPTION_KEY", undefined], + ["SESSION_ENCRYPTION_KEY", ""], + ["SESSION_ENCRYPTION_KEY", " "], + ["SESSION_ENCRYPTION_KEY", "a".repeat(63)], + ["SESSION_ENCRYPTION_KEY", "z".repeat(64)], + ["DATABASE_URL", undefined], + ["DATABASE_URL", "not-a-url"], + ["DATABASE_URL", "https://127.0.0.1/chopin"], + ["DATABASE_URL", "postgresql://test:fake-password@remote.example/chopin"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?host=remote.example"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?hostaddr=192.0.2.1"], + ["DATABASE_URL", "postgresql://test:fake-password@127.0.0.1/chopin?service=production"], +]; + +for (let [index, [name, value]] of rejected.entries()) { + it(`refuses invalid ${name} (${index}) before caller effects`, () => { + let effects = 0; + let env = { ...approved, [name]: value }; + expect(() => { + requireScriptedServer(env, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); +} + +it("never includes supplied database or provider secrets in refusal messages", () => { + for (let name of ["DATABASE_URL", "JEV_API_KEY", "TYPESAFE_API_KEY"]) { + let failure: unknown; + try { + requireScriptedServer({ ...approved, [name]: "confidential-test-value" }, root); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).not.toContain("confidential-test-value"); + } +}); + +it("accepts supplied local PostgreSQL URLs without contacting their database", () => { + for (let host of ["127.0.0.1", "localhost", "[::1]"]) { + for (let protocol of ["postgres", "postgresql"]) { + expect(() => + requireScriptedServer({ + ...approved, + DATABASE_URL: `${protocol}://test:fake-password@${host}:5433/chopin`, + }, root) + ).not.toThrow(); + } + } +}); + +let proxyNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +]; +for (let name of proxyNames) { + for ( + let value of [undefined, " ", "http://proxy-user:confidential-proxy-secret@proxy.invalid:8080"] + ) { + it(`refuses transport proxy ${name} (${value === undefined ? "missing" : value === " " ? "blank" : "set"}) before caller effects`, () => { + let effects = 0; + expect(() => { + requireScriptedServer({ ...approved, [name]: value }, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); + } +} + +for (let name of ["NO_PROXY", "no_proxy"]) { + for (let [index, value] of [undefined, "", "127.0.0.1", " * "].entries()) { + it(`requires universal transport proxy bypass ${name} (${index}) before caller effects`, () => { + let effects = 0; + expect(() => { + requireScriptedServer({ ...approved, [name]: value }, root); + effects++; + }).toThrow(name); + expect(effects).toBe(0); + }); + } +} + +it("never includes supplied transport proxy secrets in refusal messages", () => { + for (let name of [...proxyNames, "NO_PROXY", "no_proxy"]) { + let failure: unknown; + try { + requireScriptedServer({ + ...approved, + [name]: "http://proxy-user:confidential-proxy-secret@proxy.invalid:8080", + }, root); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(name); + expect((failure as Error).message).not.toContain("proxy-user"); + expect((failure as Error).message).not.toContain("confidential-proxy-secret"); + expect((failure as Error).message).not.toContain("proxy.invalid"); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-harness.test-driver.ts b/apps/server/src/conversation-plan/scripted-harness.test-driver.ts new file mode 100644 index 00000000..f189d04c --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-harness.test-driver.ts @@ -0,0 +1,200 @@ +import { held, readScript, substitute } from "./scripted-script"; + +import type { HarnessV1, HarnessV1StreamPart } from "@ai-sdk/harness"; +import type { Plan } from "../plan/service"; + +type Result = { toolCallId: string; toolName: string; output: unknown; isError?: boolean }; +type ToolResult = Extract; + +/** The SDK executes tools; this test driver supplies only the scripted model side. */ +export function createScriptedHarness( + dir: string, + plan: Plan, + observe: { onResult?: (result: Result) => Promise | void } = {}, +) { + let tools: string[][] = []; + let calls: Array<{ toolCallId: string; toolName: string; input: unknown }> = []; + let results: Result[] = []; + let entered = Promise.withResolvers(); + let released = Promise.withResolvers(); + let starts = 0; + let destroyed = 0; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "current-scripted-jobs", + builtinTools: {}, + async doStart(start) { + let turns = new Set<() => void>(); + let tasks = new Set>(); + let destruction: Promise | undefined; + let track = (task: Promise) => { + tasks.add(task); + void task.then(() => tasks.delete(task), () => tasks.delete(task)); + }; + let drain = async () => { + while (tasks.size) await Promise.allSettled(tasks); + }; + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + if (destruction) throw new Error("scripted Harness session is destroyed"); + starts++; + tools.push(options.tools.map(spec => spec.name)); + let job = plan.chat.job; + let turn = plan.chat.turn; + let done = Promise.withResolvers(); + let pending = new Map void; + }>(); + let submissions = new Set>(); + let stop = new AbortController(); + let finished = false; + let current = () => + !finished && !options.abortSignal?.aborted && !plan.chat.closed + && plan.chat.job === job && plan.chat.turn === turn; + let finish = () => { + if (finished) return; + finished = true; + stop.abort(); + options.abortSignal?.removeEventListener("abort", finish); + turns.delete(finish); + for (let entry of pending.values()) entry.resolve(); + pending.clear(); + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + }; + let fail = (error: unknown) => { + if (finished) return; + options.emit({ type: "error", error }); + finish(); + }; + turns.add(finish); + options.abortSignal?.addEventListener("abort", finish, { once: true }); + if (options.abortSignal?.aborted) finish(); + let driving = (async () => { + try { + if (finished) return; + if (!job || !turn) throw new Error("scripted Harness requires a current job turn"); + let script = await readScript(dir, job.kind); + if (!current()) return finish(); + entered.resolve(); + let gate = await held(dir, job.kind, stop.signal); + if (gate === "timed-out") throw new Error("Scripted Planner job hold timed out."); + if (gate !== "released" || !current()) return finish(); + released.resolve(); + for (let call of script) { + if (!current()) return finish(); + let toolCallId = crypto.randomUUID(); + let input = substitute(call.args, plan.revision, job.target); + let returned = Promise.withResolvers(); + pending.set(toolCallId, { name: call.tool, resolve: returned.resolve }); + calls.push({ toolCallId, toolName: call.tool, input }); + options.emit({ + type: "tool-call", + toolCallId, + toolName: call.tool, + input: JSON.stringify(input), + }); + await returned.promise; + } + finish(); + } catch (error) { + fail(error); + } finally { + await Promise.allSettled(submissions); + done.resolve(); + } + })(); + track(driving); + return { + done: done.promise, + submitToolResult(value) { + let submission = (async () => { + let entry = pending.get(value.toolCallId); + if (!entry || !current()) return finish(); + try { + let result: Result = { + toolCallId: value.toolCallId, + toolName: entry.name, + output: value.output, + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }; + results.push(result); + await observe.onResult?.(result); + if (!current()) return finish(); + options.emit({ + type: "tool-result", + toolCallId: value.toolCallId, + toolName: entry.name, + result: value.output as ToolResult["result"], + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }); + } catch (error) { + fail(error); + } finally { + pending.delete(value.toolCallId); + entry.resolve(); + } + })(); + submissions.add(submission); + track(submission); + void submission.then( + () => submissions.delete(submission), + () => submissions.delete(submission), + ); + return submission; + }, + }; + }, + doDestroy() { + destruction ??= (async () => { + for (let finish of turns) finish(); + await drain(); + destroyed++; + })(); + return destruction; + }, + async doCompact() { + throw new Error("scripted Harness does not support compaction"); + }, + async doContinueTurn() { + throw new Error("scripted Harness does not support continuing a turn"); + }, + async doSuspendTurn() { + throw new Error("scripted Harness does not support suspending a turn"); + }, + async doDetach() { + throw new Error("scripted Harness does not support detaching a turn"); + }, + async doStop() { + for (let finish of turns) finish(); + await drain(); + return { + type: "resume-session", + harnessId: "current-scripted-jobs", + specificationVersion: "harness-v1", + data: null, + }; + }, + }; + }, + }; + return { + fake, + tools, + calls, + results, + starts: () => starts, + destroyed: () => destroyed, + entered: entered.promise, + released: released.promise, + }; +} diff --git a/apps/server/src/conversation-plan/scripted-harness.test.ts b/apps/server/src/conversation-plan/scripted-harness.test.ts new file mode 100644 index 00000000..59e5c20f --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-harness.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { headingJob, scriptedHeading } from "../chat/job-scripted-harness.test-fixtures"; +import type { HarnessV1StreamPart } from "@ai-sdk/harness"; + +test("the scripted model contract drains an admitted result before destruction", async () => { + let h = await scriptedHeading([{ tool: "draft_heading", args: {} }]); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let controller = new AbortController(); + let session: Awaited> | undefined; + let observerFinished = false; + let destroyed = false; + let done = false; + let parts: HarnessV1StreamPart[] = []; + try { + h.opened.plan.chat.job = headingJob(); + h.opened.plan.chat.turn = { + id: "contract-turn", + handle: "chopin", + started: 1, + responded: false, + }; + h.onResult(async () => { + entered.resolve(); + await release.promise; + observerFinished = true; + }); + session = await h.driver.fake.doStart({ sessionId: "contract-session" } as never); + let prompt = await session.doPromptTurn({ + tools: [], + abortSignal: controller.signal, + emit: (part: HarnessV1StreamPart) => parts.push(part), + } as never); + void prompt.done.then(() => { + done = true; + }); + await h.driver.released; + let call = h.driver.calls[0]; + if (!call) throw new Error("missing admitted scripted tool call"); + let submitted = prompt.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + await entered.promise; + controller.abort(); + let destroying = session.doDestroy(); + void destroying.then(() => { + destroyed = true; + }); + await Bun.sleep(10); + expect(observerFinished).toBe(false); + expect(done).toBe(false); + expect(destroyed).toBe(false); + expect(h.driver.destroyed()).toBe(0); + release.resolve(); + await submitted; + await destroying; + await prompt.done; + expect(observerFinished).toBe(true); + expect(done).toBe(true); + expect(destroyed).toBe(true); + expect(h.driver.destroyed()).toBe(1); + expect(h.driver.calls).toHaveLength(1); + expect(parts.filter(part => part.type === "tool-result")).toEqual([]); + await session.doDestroy(); + expect(h.driver.destroyed()).toBe(1); + } finally { + controller.abort(); + release.resolve(); + await session?.doDestroy(); + await h.close(); + } +}); + +test("the scripted model stop cancels its held gate without aborting the caller signal", async () => { + let h = await scriptedHeading([], true); + let controller = new AbortController(); + let session: Awaited> | undefined; + let escape: ReturnType | undefined; + try { + let job = headingJob(); + let turn = { id: "stop-turn", handle: "chopin", started: 1, responded: false }; + h.opened.plan.chat.job = job; + h.opened.plan.chat.turn = turn; + session = await h.driver.fake.doStart({ sessionId: "stop-session" } as never); + let prompt = await session.doPromptTurn({ + tools: [], + abortSignal: controller.signal, + emit: (_part: HarnessV1StreamPart) => {}, + } as never); + await h.driver.entered; + escape = setTimeout(() => controller.abort(), 500); + let stopped = await session.doStop(); + expect(controller.signal.aborted).toBe(false); + clearTimeout(escape); + await prompt.done; + expect(stopped).toMatchObject({ type: "resume-session", harnessId: h.driver.fake.harnessId }); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(h.opened.plan.chat.job).toBe(job); + expect(h.opened.plan.chat.turn).toBe(turn); + expect(await h.savedSource()).toBe(""); + await session.doDestroy(); + expect(h.driver.destroyed()).toBe(1); + } finally { + clearTimeout(escape); + controller.abort(); + await session?.doDestroy(); + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-network.test.ts b/apps/server/src/conversation-plan/scripted-network.test.ts new file mode 100644 index 00000000..ef3fcb79 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-network.test.ts @@ -0,0 +1,242 @@ +import { expect, it, mock } from "bun:test"; + +import { createScriptedFetch } from "../../../../e2e/harness/scripted-network"; + +it("rejects unhandled, malformed and credential-bearing URLs before native fetch", async () => { + let native = mock(async () => new Response("unexpected network")); + let fetch = createScriptedFetch(native); + let rejected = [ + "https://provider.invalid/secret-path?token=confidential", + "https://api.github.com/user", + "https://api.githubcopilot.com/mcp/", + "http://127.0.0.1:8789/", + "http://127.0.0.1:8792/", + "http://127.0.0.1/", + "http://localhost:8788/", + "http://[::1]:8788/", + "https://127.0.0.1:8788/", + "ftp://127.0.0.1:8797/", + "file:///secret-path", + "http://confidential:password@127.0.0.1:8788/", + "http://confidential@127.0.0.1:8797/", + "not-a-url-confidential", + "/relative-confidential", + "", + ]; + for (let input of rejected) { + await expect(fetch(input)).rejects.toThrow(Error); + } + await expect(fetch(new URL("http://remote.invalid:8788/"))).rejects.toThrow(Error); + await expect(fetch(new Request("https://remote.invalid/"))).rejects.toThrow(Error); + await expect(fetch({ secret: "confidential" } as unknown as string)).rejects.toThrow(Error); + expect(native).not.toHaveBeenCalled(); +}); + +it("delegates only the two fixture origins with native redirect following disabled", async () => { + let response = new Response(null, { + status: 302, + headers: { location: "https://remote.invalid/secret-path" }, + }); + let native = mock(async (_input: Parameters[0], init?: RequestInit) => { + expect(new Request(_input, init).redirect).toBe("error"); + return response; + }); + let fetch = createScriptedFetch(native); + for (let redirect of [undefined, "follow", "manual", "error"] as const) { + for (let input of ["http://127.0.0.1:8788/health", "http://127.0.0.1:8797/mcp"]) { + expect(await fetch(input, redirect ? { redirect } : undefined)).toBe(response); + } + } + expect(native).toHaveBeenCalledTimes(8); +}); + +it("normalizes input forms and overrides while preserving body, headers and abort propagation", async () => { + let controller = new AbortController(); + let native = mock(async (input: Parameters[0], init?: RequestInit) => { + expect(input).toBeInstanceOf(Request); + expect(init).toBeUndefined(); + let request = input as Request; + expect(request.method).toBe("POST"); + expect(request.redirect).toBe("error"); + expect(request.headers.get("x-fixture")).toBe("original"); + expect(request.signal.aborted).toBe(false); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + let init = Object.freeze({ + method: "POST", + body: new Uint8Array([104, 105]), + signal: controller.signal, + headers: new Headers({ "x-fixture": "original" }), + }); + let request = new Request("http://127.0.0.1:8797/", { + method: "PUT", + body: "original request body", + redirect: "follow", + }); + for (let input of ["http://127.0.0.1:8788/", new URL("http://127.0.0.1:8797/"), request]) { + expect(await (await fetch(input, init)).text()).toBe("hi"); + } + controller.abort(); + for (let [input] of native.mock.calls) { + expect((input as Request).signal.aborted).toBe(true); + } + expect(init).not.toHaveProperty("redirect"); + expect(request.bodyUsed).toBe(false); + let inherited = createScriptedFetch(async input => { + let normalized = input as Request; + expect(normalized.method).toBe("PUT"); + expect(normalized.redirect).toBe("error"); + return new Response(await normalized.text()); + }); + expect(await (await inherited(request)).text()).toBe("original request body"); +}); + +it("guards preconnect with the same origin restriction before delegating", () => { + let native = mock(async () => new Response("unused")); + let preconnect = mock( + (_url: string | URL, _options?: Parameters[1]) => {}, + ); + let fetch = createScriptedFetch(Object.assign(native, { preconnect })); + let options = Object.freeze({ dns: true, tcp: true, https: false }); + let local = new URL("http://127.0.0.1:8797/mcp"); + fetch.preconnect("http://127.0.0.1:8788/", options); + fetch.preconnect(local, options); + expect(preconnect).toHaveBeenCalledTimes(2); + expect(preconnect.mock.calls[1]?.[0]).toBe("http://127.0.0.1:8797/mcp"); + expect(preconnect.mock.calls[1]?.[1]).toEqual({ ...options, http: undefined }); + for (let input of ["https://remote.invalid/", "http://127.0.0.1:8789/", "bad confidential"]) { + expect(() => fetch.preconnect(input)).toThrow(Error); + } + expect(preconnect).toHaveBeenCalledTimes(2); + expect(native).not.toHaveBeenCalled(); +}); + +it("offers a guarded no-op preconnect when the injected fetch has none", () => { + let native = mock(async () => new Response("unused")); + let fetch = createScriptedFetch(native); + expect(() => fetch.preconnect("http://127.0.0.1:8788/")).not.toThrow(); + expect(() => fetch.preconnect("http://127.0.0.1:8797/")).not.toThrow(); + expect(() => fetch.preconnect("https://remote.invalid/confidential")).toThrow(Error); + expect(native).not.toHaveBeenCalled(); +}); + +it("keeps refusal errors generic for malformed input and embedded secrets", async () => { + let fetch = createScriptedFetch(mock(async () => new Response("unused"))); + let messages = new Set(); + for ( + let input of [ + "confidential-malformed-input", + "http://confidential-user:confidential-password@127.0.0.1:8788/private", + "https://remote.invalid/confidential-path?token=confidential-token", + ] + ) { + let failure = await fetch(input).then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + let message = (failure as Error).message; + expect(message).not.toContain("confidential"); + expect(message).not.toContain("remote.invalid"); + messages.add(message); + let preconnectFailure: unknown; + try { + fetch.preconnect(input); + } catch (error) { + preconnectFailure = error; + } + expect(preconnectFailure).toBeInstanceOf(Error); + messages.add((preconnectFailure as Error).message); + } + expect(messages.size).toBe(1); +}); + +it("preserves inherited RequestInit fields through native request normalization", async () => { + let controller = new AbortController(); + let init = Object.create({ + method: "POST", + body: "inherited body", + headers: { "x-fixture": "inherited" }, + signal: controller.signal, + }) as RequestInit; + let native = mock(async (input: Parameters[0], options?: RequestInit) => { + let request = new Request(input, options); + expect(request.method).toBe("POST"); + expect(request.headers.get("x-fixture")).toBe("inherited"); + expect(request.signal.aborted).toBe(false); + controller.abort(); + expect(request.signal.aborted).toBe(true); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + expect(await (await fetch("http://127.0.0.1:8788/", init)).text()).toBe("inherited body"); +}); + +it("keeps a URL-mutating init getter from sending a remote destination to native fetch", async () => { + let url = new URL("http://127.0.0.1:8788/"); + let init = { + get headers() { + url.hostname = "remote.invalid"; + return { "x-fixture": "mutating getter" }; + }, + }; + let native = mock(async (input: Parameters[0], options?: RequestInit) => { + let request = new Request(input, options); + expect(request.url).toBe("http://127.0.0.1:8788/"); + return new Response("local"); + }); + let fetch = createScriptedFetch(native); + expect(await (await fetch(url, init)).text()).toBe("local"); + expect(url.hostname).toBe("remote.invalid"); +}); + +it("captures inherited preconnect options before checking a URL mutated by their getter", () => { + let url = new URL("http://127.0.0.1:8788/"); + let options = Object.create({ + get dns() { + url.hostname = "remote.invalid"; + return true; + }, + tcp: true, + }) as Parameters[1]; + let preconnect = mock( + (_input: string | URL, options?: Parameters[1]) => { + void options?.dns; + }, + ); + let fetch = createScriptedFetch(Object.assign( + mock(async () => new Response("unused")), + { preconnect }, + )); + expect(() => fetch.preconnect(url, options)).toThrow(Error); + expect(preconnect).not.toHaveBeenCalled(); +}); + +it("excludes Bun transport overrides and their getters from the native fetch boundary", async () => { + let native = mock(async (input: Parameters[0], init?: RequestInit) => { + expect(input).toBeInstanceOf(Request); + expect(init).toBeUndefined(); + expect(input).not.toHaveProperty("proxy"); + expect(input).not.toHaveProperty("unix"); + expect(input).not.toHaveProperty("tls"); + let request = input as Request; + expect(request.url).toBe("http://127.0.0.1:8788/"); + expect(request.method).toBe("POST"); + expect(request.headers.get("x-fixture")).toBe("transport overrides excluded"); + return new Response(await request.text()); + }); + let fetch = createScriptedFetch(native); + let init = { + method: "POST", + body: "standard body", + headers: { "x-fixture": "transport overrides excluded" }, + get proxy() { + throw new Error("offline proxy override was inspected"); + }, + get unix() { + throw new Error("offline Unix override was inspected"); + }, + get tls() { + throw new Error("offline TLS override was inspected"); + }, + }; + expect(await (await fetch("http://127.0.0.1:8788/", init)).text()).toBe("standard body"); +}); diff --git a/apps/server/src/conversation-plan/scripted-runner.test-chat.ts b/apps/server/src/conversation-plan/scripted-runner.test-chat.ts new file mode 100644 index 00000000..b00979cb --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.test-chat.ts @@ -0,0 +1,26 @@ +import * as Chat from "../chat/service"; +import { documentTools } from "../agent/tools"; +import { jobTools, scopedJobTools } from "../agent/job-tools"; + +export type Room = Chat.Room; + +// The historical runner calls handlers directly; this bridge exercises current tools, not a stream. +export function planTools(context: Room) { + let room = Chat.documentRoom(context); + let tools = scopedJobTools({ + edit_plan: documentTools.edit_plan, + draft_heading: jobTools.draft_heading, + }, room); + return Object.entries(tools).map(([name, tool]) => ({ + name, + handler: (input: unknown, _context: unknown) => { + let execute = tool.execute; + if (!execute) throw new Error(`no executable tool ${name}`); + return execute(input as never, { + toolCallId: `scripted:${name}`, + messages: [], + context: { room }, + }); + }, + })); +} diff --git a/apps/server/src/conversation-plan/scripted-runner.test.ts b/apps/server/src/conversation-plan/scripted-runner.test.ts new file mode 100644 index 00000000..10077d02 --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.test.ts @@ -0,0 +1,144 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as Chat from "./scripted-runner.test-chat"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { Job } from "./jobs"; +import { scriptedRunner } from "./scripted-runner"; + +let plans: Service.Plan[] = []; +let directories: string[] = []; +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + for (let directory of directories) await rm(directory, { recursive: true, force: true }); + plans = []; + directories = []; +}); + +async function directory(): Promise { + let path = await mkdtemp(join(tmpdir(), "planner-jobs-")); + directories.push(path); + return path; +} + +function heading(): Job { + return { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("a scripted heading runs the real job tool while ordinary writing is refused", async () => { + let { plan, server } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile( + join(path, "heading.json"), + JSON.stringify([ + { + tool: "edit_plan", + args: { revision: "$revision", operations: [{ op: "insert_root", source: "x\n" }] }, + }, + { + tool: "draft_heading", + args: { revision: "$revision", title: "Auth Strategy", goal: "Goal: pick auth." }, + }, + ]), + ); + let context: Chat.Room = { + chat: plan.chat, + plan, + server, + room: "test", + persist: () => Service.persist(plan), + } as Chat.Room; + let run = scriptedRunner(path, () => Chat.planTools(context), plan.chat, () => plan.revision); + expect(await run(heading(), "prompt")).toEqual({ + status: "done", + output: JSON.stringify({ title: "Auth Strategy", goal: "Goal: pick auth." }), + }); + expect(room.project(plan.document)).toBe("# Auth Strategy\n\nGoal: pick auth.\n"); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); +}); + +test("missing scripts skip, malformed scripts fail, and an active turn is never overwritten", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + let run = scriptedRunner(path, () => [], plan.chat, () => plan.revision); + expect(await run(heading(), "prompt")).toMatchObject({ status: "skipped" }); + await writeFile(join(path, "heading.json"), "{not JSON"); + expect(await run(heading(), "prompt")).toMatchObject({ status: "failed" }); + let turn = { id: "existing", handle: "chopin", started: 1, responded: false }; + plan.chat.turn = turn; + expect(await run(heading(), "prompt")).toMatchObject({ status: "failed" }); + expect(plan.chat.turn).toBe(turn); +}); + +test("a held script waits for release and aborts promptly when its job service stops", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile(join(path, "heading.json"), JSON.stringify([])); + await writeFile(join(path, "heading.hold"), "hold"); + let controller = new AbortController(); + let run = scriptedRunner(path, () => [], plan.chat, () => plan.revision, controller.signal); + let pending = run(heading(), "prompt"); + await Bun.sleep(50); + expect(plan.chat.job?.id).toBe(heading().id); + controller.abort(); + expect(await pending).toMatchObject({ status: "skipped" }); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); +}); + +test("a replaced job keeps its new turn and output after an old scripted call finishes", async () => { + let { plan } = await openPlan("\n"); + plans.push(plan); + let path = await directory(); + await writeFile( + join(path, "heading.json"), + JSON.stringify([{ + tool: "draft_heading", + args: {}, + }]), + ); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let run = scriptedRunner( + path, + () => + [{ + name: "draft_heading", + handler: async () => { + entered.resolve(); + await release.promise; + return "{}"; + }, + }] as never, + plan.chat, + () => plan.revision, + ); + let pending = run(heading(), "prompt"); + await entered.promise; + let replacement = { ...heading(), id: "heading:document:m2" }; + let turn = { id: "new-turn", handle: "chopin", started: 2, responded: false }; + plan.chat.job = replacement; + plan.chat.turn = turn; + plan.chat.jobOutput = "new output"; + release.resolve(); + expect(await pending).toMatchObject({ status: "skipped" }); + expect(plan.chat.job).toBe(replacement); + expect(plan.chat.turn).toBe(turn); + expect(plan.chat.jobOutput).toBe("new output"); +}); diff --git a/apps/server/src/conversation-plan/scripted-runner.ts b/apps/server/src/conversation-plan/scripted-runner.ts new file mode 100644 index 00000000..e370f6ba --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-runner.ts @@ -0,0 +1,110 @@ +/** Test-only Planner runner: real scoped tools, with calls read from a local script. */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { ulid } from "@chopin/dialect"; +import { type Call, calls, held, MAX_SCRIPT_BYTES, reason, substitute } from "./scripted-script"; + +type Tool = { + name: string; + handler?: (args: unknown, context: unknown) => unknown | Promise; +}; +import type { Chat } from "../chat/service"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobRunner } from "./planner-jobs"; + +export function scriptedRunner( + dir: string, + tools: (job: Job) => Tool[], + chat: Chat, + revision: () => number, + signal?: AbortSignal, +): PlannerJobRunner { + return async (job, _prompt, runnerSignal): Promise => { + let activeSignal = runnerSignal ?? signal; + let script: Call[]; + try { + let data = await readFile(join(dir, `${job.kind}.json`), "utf8"); + if (data.length > MAX_SCRIPT_BYTES) throw new Error("scripted Planner job is too large"); + script = calls(JSON.parse(data)); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { status: "skipped", reason: `no script for ${job.kind}` } + : { status: "failed", reason: reason(error) }; + } + if (activeSignal?.aborted || chat.closed) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + if (chat.busy || chat.turn || chat.job) { + return { status: "failed", reason: "Another Planner turn is active." }; + } + let turn: NonNullable = { + id: ulid(), + handle: "chopin", + started: Date.now(), + responded: false, + }; + chat.busy = true; + chat.turn = turn; + chat.job = job; + chat.jobOutput = undefined; + chat.jobFailures = 0; + chat.jobCalls = new Map(); + try { + let gate = await held(dir, job.kind, activeSignal); + if (gate === "timed-out") { + return { status: "failed", reason: "Scripted Planner job hold timed out." }; + } + if ( + gate !== "released" || activeSignal?.aborted || chat.closed + || chat.job !== job || chat.turn !== turn + ) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + let available = tools(job); + let failed: string | undefined; + for (let call of script) { + if (activeSignal?.aborted || chat.closed || chat.job !== job || chat.turn !== turn) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + let tool = available.find(item => item.name === call.tool); + if (!tool?.handler) return { status: "failed", reason: `no tool ${call.tool}` }; + try { + let result = await tool.handler( + substitute(call.args, revision(), job.target) as never, + {} as never, + ); + if (typeof result === "string") { + let parsed: unknown; + try { + parsed = JSON.parse(result); + } catch {} + if (parsed && typeof parsed === "object" && "ok" in parsed && !parsed.ok) { + failed = reason("error" in parsed ? parsed.error : result); + } + } + } catch (error) { + failed = reason(error); + } + if (activeSignal?.aborted || chat.closed || chat.job !== job || chat.turn !== turn) { + return { status: "skipped", reason: "The scripted Planner job stopped." }; + } + } + return chat.jobOutput === undefined + ? { status: "failed", reason: failed ?? "The script called no job tool successfully." } + : { status: "done", output: chat.jobOutput }; + } catch (error) { + return { status: "failed", reason: reason(error) }; + } finally { + if (chat.job === job && chat.turn === turn) { + chat.job = undefined; + chat.jobOutput = undefined; + chat.jobFailures = undefined; + chat.jobCalls = undefined; + chat.turn = undefined; + chat.busy = false; + } + } + }; +} diff --git a/apps/server/src/conversation-plan/scripted-script.test.ts b/apps/server/src/conversation-plan/scripted-script.test.ts new file mode 100644 index 00000000..094b2dea --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-script.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { held, readScript, substitute } from "./scripted-script"; + +let directories: string[] = []; +afterEach(async () => { + for (let directory of directories) await rm(directory, { recursive: true, force: true }); + directories = []; +}); + +async function directory(): Promise { + let path = await mkdtemp(join(tmpdir(), "planner-script-loader-")); + directories.push(path); + return path; +} + +async function script(value: unknown): Promise { + let path = await directory(); + await writeFile(join(path, "heading.json"), JSON.stringify(value)); + return path; +} + +test("readScript preserves exact calls through the sixteen-call limit", async () => { + let calls = Array.from({ length: 16 }, (_, index) => ({ + tool: "draft_heading", + args: { revision: "$revision", title: `Heading ${index}`, goal: "$target" }, + })); + let path = await script(calls); + expect(await readScript(path, "heading")).toEqual(calls); +}); + +test.each([ + ["seventeen calls", Array.from({ length: 17 }, () => ({ tool: "read_plan", args: {} }))], + ["extra call field", [{ tool: "read_plan", args: {}, extra: true }]], + ["array args", [{ tool: "read_plan", args: [] }]], + ["null args", [{ tool: "read_plan", args: null }]], + ["invalid tool", [{ tool: "read-plan", args: {} }]], +])("readScript rejects %s", async (_name, value) => { + let path = await script(value); + await expect(readScript(path, "heading")).rejects.toThrow("calls are invalid"); +}); + +test("readScript retains the original string length bound rather than UTF-8 byte count", async () => { + let calls = [{ tool: "draft_heading", args: { title: "é".repeat(40_000) } }]; + let path = await script(calls); + expect(await readScript(path, "heading")).toEqual(calls); + await writeFile( + join(path, "heading.json"), + JSON.stringify([ + { tool: "draft_heading", args: { title: "x".repeat(64 * 1024) } }, + ]), + ); + await expect(readScript(path, "heading")).rejects.toThrow("scripted Planner job is too large"); +}); + +test("readScript exposes the original missing-file and malformed-JSON errors", async () => { + let path = await directory(); + await expect(readScript(path, "heading")).rejects.toMatchObject({ code: "ENOENT" }); + await writeFile(join(path, "heading.json"), "{not JSON"); + await expect(readScript(path, "heading")).rejects.toBeInstanceOf(SyntaxError); +}); + +test("substitution replaces nested markers and refuses depth beyond sixteen", () => { + expect(substitute({ revision: "$revision", values: ["$target", { keep: "literal" }] }, 7, "q1")) + .toEqual({ revision: 7, values: ["q1", { keep: "literal" }] }); + let nested: unknown = "$revision"; + for (let depth = 0; depth < 16; depth++) nested = [nested]; + expect(() => substitute(nested, 7, "q1")).not.toThrow(); + expect(() => substitute([nested], 7, "q1")).toThrow("arguments are too deep"); +}); + +test("an actual hold file respects release and abort conditions", async () => { + let path = await directory(); + await writeFile(join(path, "heading.hold"), "hold"); + await writeFile(join(path, "heading.release"), "release"); + expect(await held(path, "heading")).toBe("released"); + await rm(join(path, "heading.release")); + let controller = new AbortController(); + controller.abort(); + expect(await held(path, "heading", controller.signal)).toBe("stopped"); +}); diff --git a/apps/server/src/conversation-plan/scripted-script.ts b/apps/server/src/conversation-plan/scripted-script.ts new file mode 100644 index 00000000..c8b44ceb --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-script.ts @@ -0,0 +1,79 @@ +/** Test-only script parsing and hold gates shared by deterministic Planner fixtures. */ + +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +import type { Job } from "./jobs"; + +export type Call = { tool: string; args: Record }; +export const MAX_SCRIPT_BYTES = 64 * 1024; +const MAX_CALLS = 16; + +export function reason(value: unknown): string { + return (value instanceof Error ? value.message : String(value)).trim().slice(0, 500) + || "Scripted Planner job failed."; +} + +export function substitute(value: unknown, revision: number, target: string, depth = 0): unknown { + if (depth > 16) throw new Error("scripted Planner job arguments are too deep"); + if (value === "$revision") return revision; + if (value === "$target") return target; + if (Array.isArray(value)) return value.map(item => substitute(item, revision, target, depth + 1)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(( + [key, item], + ) => [key, substitute(item, revision, target, depth + 1)]), + ); + } + return value; +} + +export function calls(value: unknown): Call[] { + if ( + !Array.isArray(value) || value.length > MAX_CALLS + || value.some(item => + !item || typeof item !== "object" || Array.isArray(item) + || Object.keys(item).some(key => key !== "tool" && key !== "args") + || typeof item.tool !== "string" || !/^[a-z_]{1,80}$/.test(item.tool) + || !item.args || typeof item.args !== "object" || Array.isArray(item.args) + ) + ) throw new Error("scripted Planner job calls are invalid"); + return value as Call[]; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export async function held( + dir: string, + kind: Job["kind"], + signal?: AbortSignal, +): Promise<"released" | "stopped" | "timed-out"> { + if (!await exists(join(dir, `${kind}.hold`))) return "released"; + let deadline = Date.now() + 30_000; + for (;;) { + if (signal?.aborted) return "stopped"; + if (Date.now() >= deadline) return "timed-out"; + if (await exists(join(dir, `${kind}.release`))) return "released"; + try { + await setTimeout(25, undefined, { signal }); + } catch { + return "stopped"; + } + } +} + +export async function readScript(dir: string, kind: Job["kind"]): Promise { + let data = await readFile(join(dir, `${kind}.json`), "utf8"); + if (data.length > MAX_SCRIPT_BYTES) throw new Error("scripted Planner job is too large"); + return calls(JSON.parse(data)); +} diff --git a/apps/server/src/conversation-plan/service-correction.test-fixtures.ts b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts new file mode 100644 index 00000000..d8d3a209 --- /dev/null +++ b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts @@ -0,0 +1,169 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, completeAnalysis, enqueue, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { entry, harness, opened } from "./service.test-fixtures"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export function excerptCorrectionSetup(options: { + text?: string; + selectedText?: string; + outcome?: "review" | "ignored" | "accepted" | "missing"; + outcomeRange?: [number, number]; + threadStatus?: "open" | "discarded"; + duplicateSource?: boolean; + linked?: boolean; +} = {}) { + let setup = harness(); + let question = entry("question", "Which service should send notification emails?"); + setup.plan.chat.entries.push(question); + let state = applyInference(initialState(), opened(question), question); + let option = entry("existing-option", "Use a current SMTP service."); + setup.plan.chat.entries.push(option); + state = applyInference(state, { + id: "option:existing", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: option.ts, + source: { + messageId: option.id, + author: option.author as ConversationPlan.SourceAuthor, + quote: option.text, + start: 0, + end: option.text.length, + role: "option", + }, + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: option.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, option); + if (options.linked !== false) { + state = applyEvent(state, { + id: "card-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "01K0N4W3B7P27CBAEC7A8C8WEA", + }); + } + let excerpt: Chat.Entry = { + ...entry( + "excerpt", + options.text ?? "By agent access I mean Copilot or bring-your-own API keys.", + ), + author: { kind: "member", handle: "alice" }, + }; + setup.plan.chat.entries.push(excerpt); + let selected = options.selectedText ?? "Copilot"; + let start = excerpt.text.indexOf(selected); + let end = start + selected.length; + if (options.duplicateSource) { + state = applyInference(state, { + id: "already-classified-excerpt", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: excerpt.ts, + source: { + messageId: excerpt.id, + author: excerpt.author as ConversationPlan.SourceAuthor, + quote: selected, + start, + end, + role: "reason", + }, + contribution: { + id: "existing-excerpt-reason", + text: selected, + authoring: "quoted", + targetId: "thread-a", + }, + }, excerpt); + } + if (options.threadStatus === "discarded") { + state = applyEvent(state, { + id: "discard-thread", + type: "thread.discarded", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: excerpt.ts, + }); + } + state = enqueue(state, excerpt.id); + state = completeAnalysis(state, excerpt.id, [], excerpt, { + questionSetVersion: "conversation-plan-5", + modelVersion: "jev-test", + status: "unlinked", + passes: [], + outcomes: options.outcome === "missing" + ? [] + : [{ + start: options.outcomeRange?.[0] ?? 0, + end: options.outcomeRange?.[1] ?? excerpt.text.length, + status: options.outcome === "accepted" ? "accepted" : options.outcome ?? "review", + gate: "unclassified excerpt", + eventIds: [], + }], + }); + setup.plan.conversationPlan = state; + let cardId = state.threads[0]!.questionnaireId; + if (cardId) { + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: options.threadStatus === "discarded" ? "cancelled" : "open", + origin: "conversation", + definition: { + questions: [{ + id: "01K0N4W3B7P27CBAEC7A8C8WEC", + header: "Notifications", + question: question.text, + multiple: false, + options: [{ + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + label: option.text, + description: "", + }], + }], + }, + history: [], + optionOrigins: {}, + editors: [], + }); + } + return { setup, excerpt, start, end }; +} + +export function excerptAction( + state: ConversationPlan.State, + excerpt: Chat.Entry, + start: number, + end: number, + change: Record = { contributionKind: "reason" }, +): ConversationPlan.CorrectionAction { + return { + actionId: "add-excerpt", + threadId: "thread-a", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: excerpt.id, + start, + end, + ...change, + } as unknown as ConversationPlan.CorrectionChange, + } as ConversationPlan.CorrectionAction; +} diff --git a/apps/server/src/conversation-plan/service-corrections.test.ts b/apps/server/src/conversation-plan/service-corrections.test.ts new file mode 100644 index 00000000..bacff86b --- /dev/null +++ b/apps/server/src/conversation-plan/service-corrections.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; + +import type { Effect } from "./effects"; + +import { createProcessor } from "./service"; + +import { excerptAction, excerptCorrectionSetup } from "./service-correction.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a human can add a saved subspan and persist its option effect before publication", async () => { + let { setup, excerpt, start, end } = excerptCorrectionSetup(); + let effectsAtPublish: Effect[][] = []; + let publish = setup.dependencies.publish; + setup.dependencies.publish = state => { + effectsAtPublish.push(structuredClone(setup.durable.pending)); + publish(state); + }; + let processor = createProcessor(setup.dependencies); + let action = excerptAction(setup.plan.conversationPlan, excerpt, start, end, { + contributionKind: "option", + }); + let result = await processor.correct(action, { kind: "member", handle: "bob" }); + let saved = setup.durable.state; + let event = saved.events.at(-1); + let quote = excerpt.text.slice(start, end); + expect(result).toEqual({ eventId: "human:bob:add-excerpt", revision: saved.revision }); + expect(event).toMatchObject({ + type: "option.added", + origin: "human", + actor: { kind: "member", handle: "bob" }, + source: { + messageId: excerpt.id, + author: excerpt.author, + quote, + start, + end, + role: "option", + }, + contribution: { text: quote, authoring: "quoted" }, + }); + expect(saved.threads[0]?.contributions.at(-1)).toMatchObject({ + text: quote, + actor: { kind: "member", handle: "bob" }, + sources: [{ messageId: excerpt.id, author: excerpt.author, quote, start, end }], + }); + expect(setup.durable.pending).toMatchObject([{ + kind: "add-option", + threadId: "thread-a", + optionId: expect.any(String), + label: quote, + trigger: event?.id, + }]); + expect(effectsAtPublish).toEqual([setup.durable.pending]); + expect(setup.publications).toEqual([saved]); + await expect(processor.correct(action, { kind: "member", handle: "bob" })).resolves.toEqual( + result, + ); + expect(setup.plan.conversationPlan.events.filter(item => item.id === result.eventId)) + .toHaveLength(1); + expect(setup.publications).toHaveLength(1); +}); + +test.each( + [ + { + name: "the selected range lies outside the reviewed outcome", + setup: { outcomeRange: [0, 4] as [number, number] }, + change: { contributionKind: "reason", targetOptionId: "01K0N4W3B7P27CBAEC7A8C8WEB" }, + expected: /outcome|range|review/i, + }, + { + name: "the analysis has no held outcome", + setup: { outcome: "missing" as const }, + change: { contributionKind: "reason" }, + expected: /outcome|review|held/i, + }, + { + name: "the analysis outcome has already been accepted", + setup: { outcome: "accepted" as const }, + change: { contributionKind: "reason" }, + expected: /outcome|review|held/i, + }, + { + name: "the expected thread version is stale", + setup: {}, + stale: true, + change: { contributionKind: "reason" }, + expected: /stale/i, + }, + { + name: "the destination thread is closed", + setup: { threadStatus: "discarded" as const }, + change: { contributionKind: "reason" }, + expected: /open|closed|discarded/i, + }, + { + name: "the destination has no linked decision card", + setup: { linked: false }, + change: { contributionKind: "option" }, + expected: /card.*open/i, + }, + { + name: "the target option does not exist", + setup: {}, + change: { contributionKind: "reason", targetOptionId: "missing-option" }, + expected: /target|option/i, + }, + { + name: "the source span has already been corrected", + setup: { duplicateSource: true }, + change: { contributionKind: "reason" }, + expected: /duplicate|already|source/i, + }, + { + name: "the request supplies a forged quote", + setup: {}, + change: { contributionKind: "reason", quote: "invented excerpt" }, + expected: /unknown|quote|source/i, + }, + ] as const, +)("rejects excerpt correction when $name", async item => { + let { setup, excerpt, start, end } = excerptCorrectionSetup(item.setup); + let before = structuredClone(setup.plan.conversationPlan); + let action = excerptAction(setup.plan.conversationPlan, excerpt, start, end, item.change); + if ("stale" in item && item.stale) action.expectedVersion--; + let processor = createProcessor(setup.dependencies); + await expect(processor.correct(action, { kind: "member", handle: "bob" })) + .rejects.toThrow(item.expected); + expect(setup.plan.conversationPlan).toEqual(before); + expect(setup.publications).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/service-effect-commit.test.ts b/apps/server/src/conversation-plan/service-effect-commit.test.ts new file mode 100644 index 00000000..370dfcbc --- /dev/null +++ b/apps/server/src/conversation-plan/service-effect-commit.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; + +import type { Effect } from "./effects"; + +import { createProcessor, MAX_PENDING_EFFECTS } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("analysis persists an insert intent before delivery and a new processor drains it", async () => { + let setup = harness(async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, + })); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.durable.state.queue.length === 0); + expect(setup.durable.pending).toMatchObject([{ key: "insert:thread-a" }]); + expect(setup.durable.receipts).toEqual([]); + processor.stop(); + + let restored = harness(); + restored.plan.chat.entries = structuredClone(setup.durable.entries); + restored.plan.conversationPlan = restoreState(setup.durable.state, restored.plan.chat.entries); + restored.plan.conversationPlanPendingEffects = structuredClone(setup.durable.pending); + let resumed = createProcessor(restored.dependencies); + let calls: string[] = []; + resumed.setEffects({ + target: () => ({ kind: "unlinked" }), + insertCard: async () => (calls.push("insert"), "Q"), + link: async () => void calls.push("link"), + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + report: error => restored.errors.push(error), + }); + resumed.wake(); + await until(() => restored.durable.receipts.includes("insert:thread-a")); + expect(calls).toEqual(["insert", "link"]); + expect(restored.durable.pending).toEqual([]); +}); + +test("a linked event commits a separate refine intent; missing job sink leaves it pending", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + let processor = createProcessor(setup.dependencies); + expect( + await processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "Q", + })), + ).toBe(true); + expect(setup.durable.pending).toMatchObject([{ + key: "job:refine:Q", + intent: { kind: "refine", target: "Q" }, + }]); + processor.wake(); + await Bun.sleep(2); + expect(setup.durable.pending).toHaveLength(1); + expect(setup.durable.receipts).toEqual([]); +}); + +test("mirrored card actions acknowledge FIFO with the event and roll back together", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let options = ["01K0N4W3B7P27CBAEC7A8C8WEB", "01K0N4W3B7P27CBAEC7A8C8WEC"]; + setup.plan.pendingCardActions = options.map((optionId, index) => ({ + id: `card:${cardId}:option:${optionId}`, + cardId, + threadId: "thread-a", + actor: index ? "ben" : "ana", + at: index + 1, + kind: "option-added" as const, + optionId, + label: `Option ${index}`, + origin: "human" as const, + })); + let processor = createProcessor(setup.dependencies); + let make = (index: number) => (state: ConversationPlan.State): ConversationPlan.Event => ({ + id: `card:${cardId}:option:${options[index]}`, + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: index ? "ben" : "ana" }, + at: index + 1, + contribution: { + id: options[index]!, + text: `Option ${index}`, + authoring: "human-edited", + targetId: "thread-a", + }, + }); + let first = setup.plan.pendingCardActions[0]!.id; + let second = setup.plan.pendingCardActions[1]!.id; + await expect(processor.record(make(1), second)).rejects.toThrow(/FIFO/); + setup.fail(); + await expect(processor.record(make(0), first)).rejects.toThrow("storage failed"); + expect(setup.plan.pendingCardActions).toHaveLength(2); + expect(setup.plan.conversationPlan.threads[0]!.contributions).toHaveLength(0); + expect(await processor.record(make(0), first)).toBe(true); + expect(setup.durable.pendingCards.map(item => item.id)).toEqual([second]); + expect(await processor.record(make(0), first)).toBe(false); + expect(await processor.record(make(1), second)).toBe(true); + expect(setup.durable.pendingCards).toEqual([]); + expect(setup.durable.state.threads[0]!.contributions.map(item => item.id)).toEqual(options); +}); + +test("a full effect outbox rolls back the analysis rather than dropping an insert", async () => { + let setup = harness(async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, + })); + setup.plan.conversationPlanPendingEffects = Array.from( + { length: MAX_PENDING_EFFECTS }, + (_, index): Effect => ({ + key: `job:existing:${index}`, + kind: "job", + intent: { kind: "prose", target: "document", trigger: `old-${index}` }, + }), + ); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Which auth system?")); + let published = setup.publications.length; + processor.afterMessage(); + await until(() => setup.errors.length > 0); + expect(String(setup.errors[0])).toContain("outbox is full"); + expect(setup.plan.conversationPlan.threads).toEqual([]); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.plan.conversationPlanPendingEffects).toHaveLength(MAX_PENDING_EFFECTS); + expect(setup.durable.state.threads).toEqual([]); + expect(setup.publications).toHaveLength(published + 1); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-effect-retry.test.ts b/apps/server/src/conversation-plan/service-effect-retry.test.ts new file mode 100644 index 00000000..f7f94c6b --- /dev/null +++ b/apps/server/src/conversation-plan/service-effect-retry.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; + +import { applyInference, initialState } from "./domain"; + +import type { Effect } from "./effects"; + +import { createProcessor, MAX_PENDING_EFFECTS } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("failed pre-link option stays ahead of the linked refine job", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlanPendingEffects = [ + { + key: "insert:thread-a", + kind: "insert-card", + threadId: "thread-a", + header: "Auth", + question: source.text, + options: [], + trigger: "open:seed", + }, + { + key: "option:01K0N4W3B7P27CBAEC7A8C8WEA", + kind: "add-option", + threadId: "thread-a", + optionId: "01K0N4W3B7P27CBAEC7A8C8WEA", + label: "GitHub Apps", + trigger: "add-1", + }, + ]; + let processor = createProcessor(setup.dependencies); + let failOption = true; + let calls: string[] = []; + processor.setEffects({ + target: id => { + let thread = setup.plan.conversationPlan.threads.find(item => item.id === id); + return thread?.questionnaireId + ? { kind: "open", id: thread.questionnaireId } + : { kind: "unlinked" }; + }, + insertCard: async () => (calls.push("insert"), "Q"), + link: async (_id, questionnaireId) => { + calls.push("link"); + await processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId, + })); + }, + addOption: async () => { + calls.push("option"); + if (failOption) throw new Error("option commit failed"); + }, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: async () => void calls.push("refine"), + report: error => setup.errors.push(error), + }); + await until(() => setup.errors.length > 0); + await Bun.sleep(10); + let failures = setup.errors.length; + await Bun.sleep(10); + expect(setup.errors).toHaveLength(failures); + expect(calls.slice(0, 2)).toEqual(["insert", "link"]); + expect(calls.slice(2).every(call => call === "option")).toBe(true); + expect(calls).not.toContain("refine"); + expect(setup.durable.pending.map(item => item.key)).toEqual([ + "option:01K0N4W3B7P27CBAEC7A8C8WEA", + "job:refine:Q", + ]); + expect(setup.durable.receipts).toEqual(["insert:thread-a"]); + failOption = false; + processor.wake(); + await until(() => setup.durable.receipts.includes("job:refine:Q")); + expect(calls.at(-2)).toBe("option"); + expect(calls.at(-1)).toBe("refine"); +}); + +test("legacy unlinked recovery inserts an empty card when old option IDs are unusable", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + let state = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlan = { + ...state, + threads: [{ + ...state.threads[0], + contributions: [{ + id: "classifier:historical", + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + }], + }; + let processor = createProcessor(setup.dependencies); + let seen: Array<{ options: Array<{ id: string; label: string }> }> = []; + processor.setEffects({ + target: () => ({ kind: "unlinked" }), + insertCard: async input => (seen.push({ options: input.options }), "Q"), + link: async () => {}, + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + report: error => setup.errors.push(error), + }); + await until(() => setup.durable.receipts.includes("insert:thread-a")); + expect(seen).toEqual([{ options: [] }]); + expect(setup.durable.pending).toEqual([]); +}); + +test("record rolls back the linked event when its refine intent cannot fit", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlanPendingEffects = Array.from( + { length: MAX_PENDING_EFFECTS }, + (_, index): Effect => ({ + key: `job:existing:${index}`, + kind: "job", + intent: { kind: "prose", target: "document", trigger: `old-${index}` }, + }), + ); + let processor = createProcessor(setup.dependencies); + await expect(processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "Q", + }))).rejects.toThrow("outbox is full"); + expect(setup.plan.conversationPlan.threads[0]?.questionnaireId).toBeUndefined(); + expect(setup.plan.conversationPlanPendingEffects).toHaveLength(MAX_PENDING_EFFECTS); + expect(setup.publications).toEqual([]); +}); + +test("repeated purpose while jobs are deferred keeps the first heading intent", async () => { + let setup = harness(async () => ({ + events: [], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "unlinked", + passes: [{ + stage: "triage", + answers: { + enough_purpose: { type: "noul", noul: 0.95 }, + }, + }], + }, + })); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("purpose-1", "We are building a private editor.")); + await processor.accept(entry("purpose-2", "It should support collaboration.")); + processor.afterMessage(); + await until(() => setup.durable.state.queue.length === 0 || setup.errors.length > 0); + expect(setup.errors).toEqual([]); + expect(setup.durable.pending).toMatchObject([{ + key: "job:heading:document", + intent: { trigger: "purpose-1" }, + }]); + expect(setup.durable.pending).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/service-inference.test.ts b/apps/server/src/conversation-plan/service-inference.test.ts new file mode 100644 index 00000000..1c61866e --- /dev/null +++ b/apps/server/src/conversation-plan/service-inference.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("card option edits during inference discard the old resolution before publication", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed", "Which auth system?"); + let choice = entry("choice", "Let's just go with Auth0."); + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + let held = deferred(); + let calls = 0; + let setup = harness(async input => { + calls++; + if (calls > 1) return unlinked(); + expect(input.linkedCards?.get("thread-a")?.options[0]?.label).toBe("Auth0"); + await held.promise; + let source = { + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + }; + return { + events: [{ + id: "mirror:choice", + type: "option.added" as const, + threadId: "thread-a", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + source: { ...source, role: "option" as const }, + contribution: { id: optionId, text: "Auth0", authoring: "scribe" as const }, + }, { + id: "settle:choice", + type: "settle.suggested" as const, + threadId: "thread-a", + observedThreadVersion: input.state.threads[0]!.version + 1, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + source: { ...source, role: "resolution" as const }, + optionId, + }], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "applied" as const, + passes: [], + }, + }; + }); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Auth0" }] }] }, + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(choice); + processor.afterMessage(); + await until(() => calls === 1); + await setup.dependencies.exclusive(async () => { + setup.plan.records.get(cardId)!.definition.questions[0]!.options[0]!.label = "Different option"; + }); + held.resolve(); + await until(() => + calls === 2 && setup.plan.conversationPlan.analysis.some(item => item.messageId === choice.id) + ); + expect(setup.plan.conversationPlan.events.some(item => item.id === "mirror:choice")).toBe(false); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + processor.stop(); +}); + +test("a scoped spike proposal commits before publication without deciding the card", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed-spike", "Which editor should we use?"); + let choice = entry("choice-spike", "I'd pick Lexical for the spike;"); + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "link-spike", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + questionnaireId: cardId, + }); + let proposed = (version: number): ConversationPlan.Event => ({ + id: "scoped:choice-spike", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: version, + origin: "classifier", + actor: { kind: "classifier" }, + at: choice.ts, + source: { + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + role: "support", + }, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }); + let setup = harness(async input => ({ + events: [proposed(input.state.threads[0]!.version)], + analysis: { questionSetVersion: "test", modelVersion: "fake", status: "applied", passes: [] }, + })); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Lexical" }] }] }, + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(choice); + processor.afterMessage(); + await until(() => setup.durable.state.events.some(item => item.id === proposed(0).id)); + let saved = restoreState(setup.durable.state, [seed, choice]); + expect(saved.threads[0]?.pendingScopedChoice).toMatchObject({ + proposalId: proposed(0).id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + proposer: "ana", + messageId: choice.id, + }); + expect(saved.threads[0]?.pendingSettle).toBeUndefined(); + expect(saved.threads[0]?.decision).toBeUndefined(); + expect(setup.plan.records.get(cardId)?.status).toBe("open"); + expect(setup.plan.conversationPlanPendingEffects).toEqual([{ + key: "scoped-choice:scoped:choice-spike:proposal", + kind: "scoped-choice", + threadId: "thread-a", + proposalId: "scoped:choice-spike", + cardId, + optionId, + label: "Lexical", + scope: "spike", + generation: 0, + triggerEventId: "scoped:choice-spike", + sources: [{ + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + role: "support", + }], + }]); + expect(setup.publications.some(item => item.events.some(event => event.id === proposed(0).id))) + .toBe(true); + let legacy = structuredClone(saved); + delete legacy.threads[0]!.pendingScopedChoice!.proposalId; + expect(restoreState(legacy, [seed, choice]).threads[0]?.pendingScopedChoice?.proposalId) + .toBe(proposed(0).id); + let ambiguous = applyInference(saved, { + ...proposed(saved.threads[0]!.version), + id: "scoped:duplicate-spike", + }, choice); + delete ambiguous.threads[0]!.pendingScopedChoice!.proposalId; + expect(() => restoreState(ambiguous, [seed, choice])).toThrow( + "scoped choice proposal is ambiguous or missing", + ); + processor.stop(); + let mismatched = harness(async input => ({ + events: [proposed(input.state.threads[0]!.version)], + analysis: { questionSetVersion: "test", modelVersion: "fake", status: "applied", passes: [] }, + })); + mismatched.plan.chat.entries.push(seed); + mismatched.plan.conversationPlan = state; + mismatched.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Another editor" }] }] }, + } as never); + let rejected = createProcessor(mismatched.dependencies); + await rejected.accept(choice); + rejected.afterMessage(); + await until(() => mismatched.durable.state.analysis.some(item => item.messageId === choice.id)); + expect(mismatched.durable.state.events.some(item => item.id === proposed(0).id)).toBe(false); + expect(mismatched.durable.state.threads[0]?.pendingScopedChoice).toBeUndefined(); + rejected.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts b/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts new file mode 100644 index 00000000..397c68a2 --- /dev/null +++ b/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a legacy accepted withdrawal recovers its missing advisory clear and prompt work", async () => { + let setup = harness(); + let question = entry("question", "Which auth system?"); + let proposal = entry("m5", "Let's settle on GitHub Apps"); + let withdrawal = entry("m7", "I no longer support GitHub Apps"); + setup.plan.chat.entries.push(question, proposal, withdrawal); + let state = applyInference(initialState(), opened(question), question); + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + state = applyEvent(state, { + id: "option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 1000, + contribution: { id: optionId, text: "GitHub Apps", authoring: "human-edited" }, + }); + state = applyInference(state, { + id: "proposed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + optionId, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + }, proposal); + state = applyInference(state, { + id: "withdrawn", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: withdrawal.ts, + optionId, + position: "oppose", + source: { + messageId: withdrawal.id, + author: withdrawal.author as ConversationPlan.SourceAuthor, + quote: withdrawal.text, + start: 0, + end: withdrawal.text.length, + role: "objection", + }, + }, withdrawal); + expect(restoreState(state, setup.plan.chat.entries)).toEqual(state); + setup.plan.conversationPlan = state; + let processor = createProcessor(setup.dependencies); + setup.fail(); + processor.wake(); + await until(() => setup.errors.length === 1); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + expect(setup.publications).toEqual([]); + processor.wake(); + await until(() => setup.durable.pending.some(effect => effect.key === "prompt:withdrawn")); + expect(setup.durable.pending).toMatchObject([ + { kind: "insert-card" }, + { key: "suggest:withdrawn", kind: "suggest", messageIds: [] }, + { key: "prompt:withdrawn", kind: "prompt", sourceMessageIds: [] }, + ]); + expect(setup.publications).toEqual([]); + let recovered = structuredClone(setup.durable.pending); + processor.stop(); + let resumed = createProcessor(setup.dependencies); + resumed.wake(); + await Bun.sleep(10); + expect(setup.durable.pending).toEqual(recovered); + resumed.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-memory-failure.test.ts b/apps/server/src/conversation-plan/service-memory-failure.test.ts new file mode 100644 index 00000000..04f84444 --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory-failure.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { entry, until } from "./service.test-fixtures"; +import { memoryProcessor, opening } from "./service-memory.test-fixtures"; + +test("real memory acceptance failure rolls back transcript and queue without publication", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + setup.failNextCommit(); + await expect(processor.accept(entry("memory-rejected", "Which auth system?"))).rejects.toThrow( + "memory commit rejected", + ); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan.queue).toEqual([]); + expect(setup.publications).toEqual([]); + let saved = await setup.saved(); + expect(saved.transcript).toEqual([]); + expect(saved.conversationPlan).toBeUndefined(); + expect(setup.fatals).toHaveLength(1); + await setup.reopen(); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan.queue).toEqual([]); + } finally { + await setup.close(); + } +}); + +test("real memory completion failure preserves pending work and a reopened processor delivers its outbox", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + await processor.accept(entry("memory-completion", "Which auth system?")); + setup.failNextCommit(); + processor.afterMessage(); + await until(() => setup.errors.length === 1); + expect(setup.publications).toHaveLength(1); + expect(setup.publications[0]!.analysis).toEqual([]); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + let failed = await setup.saved(); + expect(failed.conversationPlan.analysis).toEqual([]); + expect(failed.conversationPlan.queue[0]?.status).toBe("pending"); + await setup.reopen(); + processor = setup.start(opening); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + let resumed = await setup.saved(); + expect(resumed.conversationPlan.queue).toEqual([]); + expect(resumed.conversationPlan.analysis[0]?.status).toBe("applied"); + expect(resumed.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.fatals).toHaveLength(1); + } finally { + await setup.close(); + } +}); + +test("real memory receipt failure retains the effect key for callback replay after open", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + await processor.accept(entry("memory-receipt", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + setup.failNextCommit(); + processor.setEffects(setup.sink); + await until(() => setup.errors.length === 1); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(setup.plan.conversationPlanEffects).toEqual([]); + let rejected = await setup.saved(); + expect(rejected.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(rejected.conversationPlanEffects).toBeUndefined(); + expect(setup.fatals).toHaveLength(1); + await setup.reopen(); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + processor = setup.start(); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + expect(setup.calls).toEqual(["insert", "link", "insert", "link"]); + let delivered = await setup.saved(); + expect(delivered.conversationPlanPendingEffects).toBeUndefined(); + expect(delivered.conversationPlanEffects).toEqual(["insert:thread-a"]); + } finally { + await setup.close(); + } +}); diff --git a/apps/server/src/conversation-plan/service-memory.test-fixtures.ts b/apps/server/src/conversation-plan/service-memory.test-fixtures.ts new file mode 100644 index 00000000..143d84dc --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory.test-fixtures.ts @@ -0,0 +1,112 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as PlanService from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createProcessor, type Dependencies, type EffectCommands } from "./service"; +import { opened, unlinked } from "./service.test-fixtures"; + +export async function memoryProcessor() { + let context = await openPlan(); + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.plan.persistence.fatal = context.backend.fatal; + let plan = context.plan; + let processor: ReturnType | undefined; + let errors: unknown[] = []; + let publications: ConversationPlan.State[] = []; + let committed = structuredClone(plan.conversationPlan); + let calls: string[] = []; + let originalCommit = context.storage.collaboration.commit; + let fail = false; + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("memory commit rejected"); + } + return originalCommit(input); + }; + function start(interpret: NonNullable = async () => unlinked()) { + let captured = plan; + processor = createProcessor({ + plan: captured, + exclusive: action => PlanService.exclusive(captured, action), + persist: async () => { + await PlanService.persistExclusive(captured); + committed = structuredClone(captured.conversationPlan); + }, + publish: state => { + expect(state).toEqual(committed); + publications.push(structuredClone(state)); + }, + active: () => true, + interpret, + onError: error => errors.push(error), + }); + return processor; + } + let sink: EffectCommands = { + target: () => ({ kind: "unlinked" }), + insertCard: async () => { + calls.push("insert"); + return "01K0N4W3B7P27CBAEC7A8C8WEA"; + }, + link: async () => { + calls.push("link"); + }, + addOption: async () => { + calls.push("option"); + }, + suggest: async () => { + calls.push("suggest"); + }, + prompt: async () => { + calls.push("prompt"); + }, + report: error => errors.push(error), + }; + return { + get plan() { + return plan; + }, + start, + sink, + calls, + errors, + publications, + fatals, + failNextCommit() { + fail = true; + }, + async saved() { + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + return (loaded!.sidecar ?? loaded!.snapshot!.sidecar) as unknown as { + transcript: unknown[]; + conversationPlan: ConversationPlan.State; + conversationPlanRetries?: Array<{ id: string; messageId: string }>; + conversationPlanPendingEffects?: unknown[]; + conversationPlanEffects?: string[]; + }; + }, + async reopen() { + processor?.stop(); + await PlanService.close(plan); + plan = await PlanService.open(context.channel.id, context.backend, context.server); + committed = structuredClone(plan.conversationPlan); + plan.persistence.fatal = context.backend.fatal; + }, + async close() { + processor?.stop(); + await PlanService.close(plan); + }, + }; +} + +export let opening: NonNullable = async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, +}); diff --git a/apps/server/src/conversation-plan/service-memory.test.ts b/apps/server/src/conversation-plan/service-memory.test.ts new file mode 100644 index 00000000..09a7b0c4 --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { entry, until } from "./service.test-fixtures"; +import { memoryProcessor, opening } from "./service-memory.test-fixtures"; + +test("real memory storage commits transcript and queue, then resumes analysis and receipts across opens", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + let message = entry("memory-open", "Which auth system?"); + await processor.accept(message); + let accepted = await setup.saved(); + expect(accepted.transcript).toEqual([message]); + expect(accepted.conversationPlan.queue).toEqual([{ + messageId: message.id, + status: "pending", + attempts: 0, + }]); + expect(setup.publications).toEqual([]); + await setup.reopen(); + expect(setup.plan.chat.entries).toEqual([message]); + processor = setup.start(opening); + processor.wake(); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + let analyzed = await setup.saved(); + expect(analyzed.conversationPlan.queue).toEqual([]); + expect(analyzed.conversationPlan.events.map(event => event.id)).toEqual(["open:memory-open"]); + expect(analyzed.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(analyzed.conversationPlanEffects).toBeUndefined(); + await setup.reopen(); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + processor = setup.start(); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + let delivered = await setup.saved(); + expect(delivered.conversationPlanPendingEffects).toBeUndefined(); + expect(delivered.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + await setup.reopen(); + processor = setup.start(); + processor.setEffects(setup.sink); + await Bun.sleep(10); + expect(setup.plan.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.errors).toEqual([]); + } finally { + await setup.close(); + } +}); + +test("real memory storage keeps failed inference and durable retry identity across open", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(async () => { + throw new Error("injected interpreter failure"); + }); + await processor.accept(entry("memory-retry", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.publications.some(state => state.queue[0]?.status === "failed")); + let failed = await setup.saved(); + expect(failed.conversationPlan.analysis[0]).toMatchObject({ + status: "failed", + error: "Jev interpretation failed", + }); + await setup.reopen(); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("failed"); + processor = setup.start(opening); + await processor.retry("memory-retry-action", "memory-retry", { kind: "member", handle: "ana" }); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + let retried = await setup.saved(); + expect(retried.conversationPlanRetries).toEqual([{ + id: "human:ana:memory-retry-action", + messageId: "memory-retry", + }]); + expect(retried.conversationPlan.queue).toEqual([]); + await setup.reopen(); + processor = setup.start(); + expect( + await processor.retry("memory-retry-action", "memory-retry", { + kind: "member", + handle: "ana", + }), + ).toEqual({ messageId: "memory-retry", queued: false }); + expect(setup.plan.conversationPlanRetries).toEqual(retried.conversationPlanRetries!); + } finally { + await setup.close(); + } +}); diff --git a/apps/server/src/conversation-plan/service-mirror-race.test.ts b/apps/server/src/conversation-plan/service-mirror-race.test.ts new file mode 100644 index 00000000..37284d3b --- /dev/null +++ b/apps/server/src/conversation-plan/service-mirror-race.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; +import { mirroredEvent } from "./card-mirror"; +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("in-flight agreement waits for Save and Reopen mirrors, then reinterprets", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed", "Which auth system?"); + let assent = { + ...entry("assent", "GitHub Apps sounds good."), + author: { kind: "member" as const, handle: "ben" }, + }; + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: `card:${cardId}:option:${optionId}`, + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 1000, + contribution: { id: optionId, text: "GitHub Apps", authoring: "human-edited" }, + }); + state = applyEvent(state, { + id: `card:${cardId}:linked`, + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + state = applyEvent(state, { + id: "suggest:seed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + optionId, + source: { + messageId: seed.id, + author: seed.author as ConversationPlan.SourceAuthor, + quote: seed.text, + start: 0, + end: seed.text.length, + role: "resolution", + }, + }); + let held = deferred>(); + let calls = 0; + let setup = harness(async ({ state: input }) => { + calls++; + if (calls > 1) return unlinked(); + return held.promise.then(() => ({ + events: [{ + id: "agree:assent", + type: "settle.agreed" as const, + threadId: "thread-a", + observedThreadVersion: input.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + optionId, + source: { + messageId: assent.id, + author: assent.author, + quote: assent.text, + start: 0, + end: assent.text.length, + role: "support" as const, + }, + }], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied" as const, + passes: [], + }, + })); + }); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + status: "open", + history: [], + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(assent); + processor.afterMessage(); + await until(() => calls === 1); + await setup.dependencies.exclusive(async () => { + setup.plan.records.set(cardId, { + status: "reopened", + history: [{ choices: [optionId], owner: "ana", at: 1000 }], + } as never); + setup.plan.pendingCardActions = [{ + id: `card:${cardId}:decided:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 1000, + kind: "decided", + generation: 1, + optionIds: [optionId], + text: "GitHub Apps", + }, { + id: `card:${cardId}:reopened:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 1000, + kind: "reopened", + generation: 1, + }]; + await setup.dependencies.persist(); + }); + let checked = deferred(); + let exclusive = setup.dependencies.exclusive; + setup.dependencies.exclusive = async action => { + let result = await exclusive(action); + checked.resolve(); + return result; + }; + held.resolve(unlinked()); + await checked.promise; + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(calls).toBe(1); + expect(setup.errors).toEqual([]); + expect(setup.plan.conversationPlan.events.some(event => event.type === "settle.agreed")) + .toBe(false); + expect( + setup.durable.pending.filter(effect => effect.kind === "prompt").map(effect => effect.key), + ) + .toEqual(["prompt:suggest:seed"]); + let actions = setup.plan.pendingCardActions.slice(); + for (let action of actions) { + await processor.record(current => { + let thread = current.threads.find(item => item.id === action.threadId)!; + return mirroredEvent(thread, action); + }, action.id); + } + await until(() => setup.plan.conversationPlan.queue.length === 0); + expect(calls).toBe(2); + expect(setup.plan.conversationPlan.threads[0]?.status).toBe("reopened"); + expect(setup.errors).toEqual([]); + expect( + setup.durable.pending.filter(effect => effect.kind === "prompt").map(effect => effect.key), + ) + .toEqual(["prompt:suggest:seed"]); +}); 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/conversation-plan/service-order.test.ts b/apps/server/src/conversation-plan/service-order.test.ts new file mode 100644 index 00000000..1be4d687 --- /dev/null +++ b/apps/server/src/conversation-plan/service-order.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { createProcessor } from "./service"; +import { entry, harness } from "./service.test-fixtures"; + +test("processor captures plan and initial effects once before reporting dependency", async () => { + let setup = harness(); + let order: string[] = []; + let planReads = 0; + let effectReads = 0; + let alternate = structuredClone(setup.plan); + Object.defineProperty(setup.dependencies, "plan", { + get() { + order.push("plan"); + return ++planReads === 1 ? setup.plan : alternate; + }, + }); + Object.defineProperty(setup.dependencies, "effects", { + get() { + order.push("effects"); + effectReads++; + if (effectReads > 1) throw new Error("effects reread"); + return undefined; + }, + }); + let onError = setup.dependencies.onError; + Object.defineProperty(setup.dependencies, "onError", { + get() { + order.push("onError"); + return onError; + }, + }); + let processor = createProcessor(setup.dependencies); + try { + expect(order).toEqual(["plan", "effects", "onError"]); + expect(Object.keys(processor)).toEqual([ + "accept", + "afterMessage", + "correct", + "saveScopedChoice", + "researchConsent", + "record", + "retry", + "setEffects", + "wake", + "stop", + "idle", + ]); + await processor.accept(entry("captured-plan", "Which auth system?")); + expect(planReads).toBe(1); + expect(effectReads).toBe(1); + expect(setup.plan.chat.entries.map(message => message.id)).toEqual(["captured-plan"]); + expect(alternate.chat.entries).toEqual([]); + } finally { + processor.stop(); + } +}); diff --git a/apps/server/src/conversation-plan/service-queue.test.ts b/apps/server/src/conversation-plan/service-queue.test.ts new file mode 100644 index 00000000..430435d6 --- /dev/null +++ b/apps/server/src/conversation-plan/service-queue.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; + +import { applyInference, initialState, restoreState } from "./domain"; + +import { createProcessor } from "./service"; + +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("acceptance persists transcript and queue together, then runs FIFO with prior context", async () => { + let seen: Array<{ id: string; recent: string[] }> = []; + let first = deferred>(); + let setup = harness(async input => { + seen.push({ id: input.message.id, recent: input.recent.map(item => item.id) }); + return input.message.id === "one" ? first.promise : unlinked(); + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "First proposal.")); + await processor.accept(entry("two", "Second proposal.")); + expect(setup.durable.entries.map(item => item.id)).toEqual(["one", "two"]); + expect(setup.durable.state.queue.map(item => item.messageId)).toEqual(["one", "two"]); + expect(setup.publications).toHaveLength(0); + processor.afterMessage(); + await until(() => seen.length === 1); + first.resolve(unlinked()); + await until(() => setup.durable.state.queue.length === 0); + expect(seen).toEqual([ + { id: "one", recent: [] }, + { id: "two", recent: ["one"] }, + ]); + expect(setup.publications.at(-1)).toEqual(setup.durable.state); +}); + +test("failed acceptance rolls back both values and never publishes", async () => { + let setup = harness(); + let processor = createProcessor(setup.dependencies); + setup.fail(); + await expect(processor.accept(entry("one", "Should we use an outline?"))) + .rejects.toThrow("storage failed"); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan).toEqual(initialState()); + expect(setup.durable.entries).toEqual([]); + expect(setup.publications).toEqual([]); +}); + +test("failed completion keeps pending work for the next open and publishes nothing new", async () => { + let response = deferred>(); + let started = false; + let setup = harness(async () => { + started = true; + return response.promise; + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "An option.")); + processor.afterMessage(); + await until(() => started); + let published = setup.publications.length; + setup.fail(); + response.resolve(unlinked()); + await until(() => setup.errors.length === 1); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.durable.state.queue[0]?.status).toBe("pending"); + expect(setup.publications).toHaveLength(published); +}); + +test("stale inference reruns after a correction without losing a later enqueue", async () => { + let pending = deferred>(); + let calls = 0; + let setup = harness(async () => { + calls++; + return calls === 1 ? pending.promise : unlinked(); + }); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Maybe make it optional.")); + processor.afterMessage(); + await until(() => calls === 1); + await processor.accept(entry("two", "I agree with optional.")); + await processor.correct({ + actionId: "edit-1", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Which outline should we use?" }, + }, { kind: "member", handle: "ana" }); + pending.resolve(unlinked()); + await until(() => setup.plan.conversationPlan.queue.length === 0); + expect(calls).toBe(3); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe( + "Which outline should we use?", + ); + expect(setup.plan.conversationPlan.analysis.map(item => item.messageId)).toEqual([ + "one", + "two", + ]); +}); + +test("stop leaves in-flight work durable and a restored processor resumes it", async () => { + let pending = deferred>(); + let started = false; + let setup = harness(async () => { + started = true; + return pending.promise; + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Use an outline.")); + processor.afterMessage(); + await until(() => started); + processor.stop(); + pending.resolve(unlinked()); + await Bun.sleep(2); + expect(setup.durable.state.queue[0]?.status).toBe("pending"); + let restored = harness(); + restored.plan.chat.entries = structuredClone(setup.durable.entries); + restored.plan.conversationPlan = restoreState( + setup.durable.state, + restored.plan.chat.entries, + ); + let resumed = createProcessor(restored.dependencies); + resumed.wake(); + await until(() => restored.plan.conversationPlan.queue.length === 0); + expect(restored.plan.conversationPlan.analysis[0]?.messageId).toBe("one"); +}); + +test("failed analysis is durable; retry keys are idempotent and collision checked", async () => { + let calls = 0; + let setup = harness(async () => { + calls++; + return calls === 1 + ? { + events: [], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "failed", + passes: [], + error: "temporary failure", + }, + } + : unlinked(); + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "An option.")); + processor.afterMessage(); + await until(() => setup.durable.state.queue[0]?.status === "failed"); + expect(setup.durable.state.analysis[0]?.error).toBe("temporary failure"); + await processor.retry("retry-1", "one", { kind: "member", handle: "ana" }); + await until(() => setup.durable.state.queue.length === 0); + expect(calls).toBe(2); + expect(await processor.retry("retry-1", "one", { kind: "member", handle: "ana" })) + .toEqual({ messageId: "one", queued: false }); + await expect(processor.retry("retry-1", "other", { kind: "member", handle: "ana" })) + .rejects.toThrow("collision"); + expect(setup.durable.retries).toEqual([{ id: "human:ana:retry-1", messageId: "one" }]); +}); + +test("a full analysis queue skips new work without rejecting chat", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + let prior = applyInference(initialState(), opened(seed), seed); + setup.plan.conversationPlan = { + ...prior, + queue: Array.from({ length: 128 }, (_, index) => ({ + messageId: `old-${index}`, + status: "failed" as const, + attempts: 0, + })), + analysis: Array.from({ length: 64 }, (_, index) => ({ + messageId: `analysis-${index}`, + questionSetVersion: "test", + modelVersion: "fake", + status: "unlinked" as const, + passes: [], + eventIds: [], + })), + }; + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("new", "A new question.")); + expect(setup.durable.entries.at(-1)?.id).toBe("new"); + expect(setup.durable.state.events).toEqual(prior.events); + expect(setup.durable.state.threads).toEqual(prior.threads); + expect(setup.durable.state.analysis.at(-1)).toMatchObject({ + messageId: "new", + status: "unlinked", + policyGate: "queue full; skipped", + }); + expect(setup.durable.state.analysis).toHaveLength(64); + expect(setup.durable.state.analysis[0]?.messageId).toBe("analysis-1"); + expect(setup.durable.state.revision).toBe(prior.revision + 1); + expect(setup.durable.state.queue).toHaveLength(128); + expect(setup.publications).toHaveLength(0); + let accepted = setup.plan.conversationPlan; + setup.fail(); + await expect(processor.accept(entry("newer", "Another question."))) + .rejects.toThrow("storage failed"); + expect(setup.plan.conversationPlan).toBe(accepted); + expect(setup.plan.chat.entries.map(item => item.id)).toEqual(["seed", "new"]); + expect(setup.durable.entries.map(item => item.id)).toEqual(["seed", "new"]); +}); + +test("inactive and forged human actions cannot mutate the sidecar", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + await expect(processor.correct({ + actionId: "bad", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Forged" }, + }, { kind: "classifier" } as never)).rejects.toThrow("member"); + setup.active = false; + await expect(processor.correct({ + actionId: "archived", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Archived" }, + }, { kind: "member", handle: "ana" })).rejects.toThrow("unavailable"); + await expect(processor.retry("retry", "seed", { kind: "member", handle: "ana" })) + .rejects.toThrow("unavailable"); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe(seed.text); +}); + +test("failed correction commit restores the previous card and emits no change", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + setup.fail(); + await expect(processor.correct({ + actionId: "edit-failed", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Different question" }, + }, { kind: "member", handle: "ana" })).rejects.toThrow("storage failed"); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe(seed.text); + expect(setup.publications).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-save.test.ts b/apps/server/src/conversation-plan/service-scoped-save.test.ts new file mode 100644 index 00000000..9414f12c --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-save.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; + +import { restoreState } from "./domain"; + +import { createProcessor } from "./service"; + +import { + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a member can Save an m6-only scoped choice without answering the card", async () => { + let fixture = scopedSaveFixture(); + let { setup } = fixture; + let processor = createProcessor(setup.dependencies); + let input = scopedChoiceSaveInput(fixture); + let result = await saveScopedChoice(processor, input); + let saved = setup.durable.state.events.at(-1); + let savedEvent = saved as unknown as { + type: string; + proposalId: string; + agreementId?: string; + } | undefined; + + expect(result.eventId).toBe("human:Rob:save-spike-choice"); + expect(savedEvent).toMatchObject({ + type: "scoped-choice.saved", + origin: "human", + actor: { kind: "member", handle: "Rob" }, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + label: "Lexical", + scope: "spike", + }); + expect(savedEvent?.agreementId).toBeUndefined(); + let proposalEvent = setup.durable.state.events.find(event => event.id === savedEvent?.proposalId); + expect(proposalEvent && "source" in proposalEvent ? proposalEvent.source : undefined) + .toEqual(fixture.proposal.source); + let savedThread = setup.plan.conversationPlan.threads[0]!; + expect(savedThread).toMatchObject({ + status: "exploring", + pendingScopedChoice: { proposalId: fixture.proposal.id, optionId: fixture.optionId }, + decisionHistory: [], + }); + expect(savedThread.pendingSettle).toBeUndefined(); + expect(savedThread.decision).toBeUndefined(); + expect(setup.plan.records.get(fixture.cardId)).toMatchObject({ status: "open", history: [] }); + expect(setup.publications.at(-1)?.events.at(-1)).toMatchObject({ + type: "scoped-choice.saved", + id: result.eventId, + }); + expect(restoreState(setup.durable.state, setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + let altered = structuredClone(setup.durable.state); + let alteredSave = altered.events.at(-1); + if (alteredSave?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + alteredSave.sources[0].quote = "I would pick another editor for the spike."; + expect(() => restoreState(altered, setup.plan.chat.entries)).toThrow(); + processor.stop(); +}); + +test("Save optionally links Rob's m7 agreement as a second exact source", async () => { + let fixture = scopedSaveFixture(true); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(event => event.id === result.eventId); + let savedEvent = saved as unknown as { + type: string; + proposalId: string; + agreementId?: string; + } | undefined; + + expect(savedEvent).toMatchObject({ + type: "scoped-choice.saved", + proposalId: fixture.proposal.id, + agreementId: fixture.agreement.id, + }); + let proposalEvent = fixture.setup.durable.state.events.find(event => + event.id === savedEvent?.proposalId + ); + let agreementEvent = fixture.setup.durable.state.events.find(event => + event.id === savedEvent?.agreementId + ); + expect(proposalEvent && "source" in proposalEvent ? proposalEvent.source : undefined) + .toEqual(fixture.proposal.source); + expect(agreementEvent && "source" in agreementEvent ? agreementEvent.source : undefined) + .toEqual(fixture.agreement.source); + expect(fixture.setup.plan.records.get(fixture.cardId)).toMatchObject({ + status: "open", + history: [], + }); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-stale.test.ts b/apps/server/src/conversation-plan/service-scoped-stale.test.ts new file mode 100644 index 00000000..46ea9716 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-stale.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; + +import { applyInference } from "./domain"; + +import { createProcessor } from "./service"; + +import { entry } from "./service.test-fixtures"; +import { + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("optionless proposer non-support rejects a stale scoped Save command", async () => { + let fixture = scopedSaveFixture(); + let { setup } = fixture; + let opposition = entry("save-mei-optionless", "I don't support that choice anymore."); + opposition.author = { kind: "member", handle: "Mei" }; + opposition.ts = 1003; + setup.plan.conversationPlan = applyInference(setup.plan.conversationPlan, { + id: "save-mei-optionless-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: opposition.ts, + source: { + messageId: opposition.id, + author: opposition.author, + quote: opposition.text, + start: 0, + end: opposition.text.length, + role: "objection", + }, + position: "oppose", + }, opposition); + setup.plan.chat.entries.push(opposition); + await setup.dependencies.persist(); + let processor = createProcessor(setup.dependencies); + try { + await expect(saveScopedChoice(processor, scopedChoiceSaveInput(fixture))) + .rejects.toThrow(); + expect(setup.durable.state.events.some(event => event.type === "scoped-choice.saved")) + .toBe(false); + } finally { + processor.stop(); + } +}); + +test("scoped Save is idempotent and rejects stale card snapshots", async () => { + let fixture = scopedSaveFixture(); + let processor = createProcessor(fixture.setup.dependencies); + let input = scopedChoiceSaveInput(fixture); + let first = await saveScopedChoice(processor, input); + let publicationCount = fixture.setup.publications.length; + let duplicate = await saveScopedChoice(processor, input); + expect(duplicate).toEqual(first); + await expect(saveScopedChoice(processor, { ...input, expectedGeneration: 1 })) + .rejects.toThrow("scoped choice action ID already used"); + expect( + fixture.setup.durable.state.events.filter(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toHaveLength(1); + expect(fixture.setup.publications).toHaveLength(publicationCount); + processor.stop(); + + for (let stale of ["generation", "label", "closed card"] as const) { + let next = scopedSaveFixture(); + let record = next.setup.plan.records.get(next.cardId) as { + status: string; + history: unknown[]; + definition: { questions: Array<{ options: Array<{ id: string; label: string }> }> }; + }; + let staleInput = scopedChoiceSaveInput(next, `stale-${stale}`); + if (stale === "generation") staleInput.expectedGeneration = 1; + if (stale === "label") record.definition.questions[0]!.options[0]!.label = "Tiptap"; + if (stale === "closed card") record.status = "decided"; + let staleProcessor = createProcessor(next.setup.dependencies); + await expect(saveScopedChoice(staleProcessor, staleInput)).rejects.toThrow(); + expect( + next.setup.plan.conversationPlan.events.some(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toBe(false); + expect(next.setup.publications).toEqual([]); + staleProcessor.stop(); + } +}); + +test("a failed scoped Save commit publishes no saved choice", async () => { + let fixture = scopedSaveFixture(); + let processor = createProcessor(fixture.setup.dependencies); + fixture.setup.fail(); + await expect(saveScopedChoice(processor, scopedChoiceSaveInput(fixture))) + .rejects.toThrow("storage failed"); + expect( + fixture.setup.plan.conversationPlan.events.some(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toBe(false); + expect(fixture.setup.publications).toEqual([]); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-support.test.ts b/apps/server/src/conversation-plan/service-scoped-support.test.ts new file mode 100644 index 00000000..2d139291 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-support.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; + +import { applyInference, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { entry } from "./service.test-fixtures"; +import { + addScopedSupport, + retractScopedSupport, + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("scoped Save retains exact sources from every current supporter", async () => { + let fixture = scopedSaveFixture(true); + let jo = addScopedSupport(fixture, "Jo", "save-m8-jo", "save-agreement-jo"); + await fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([ + fixture.proposal.source, + fixture.agreement.source, + jo.agreement.source, + ]); + expect(fixture.setup.durable.state.events.filter(item => item.type === "scoped-choice.saved")) + .toHaveLength(1); + let thread = fixture.setup.plan.conversationPlan.threads[0]; + expect(thread).toMatchObject({ + status: "exploring", + decisionHistory: [], + }); + expect(thread?.pendingSettle).toBeUndefined(); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("a proposer withdrawal does not remove a remaining supporter's Save path", async () => { + let fixture = scopedSaveFixture(true); + retractScopedSupport(fixture, "Mei", "save-mei-withdraws"); + fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([fixture.agreement.source]); + expect(saved.sources).not.toContain(fixture.proposal.source); + let thread = fixture.setup.plan.conversationPlan.threads[0]; + expect(thread).toMatchObject({ + status: "exploring", + decisionHistory: [], + }); + expect(thread?.pendingSettle).toBeUndefined(); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("retracting one supporter keeps the other current supporter in the saved sources", async () => { + let fixture = scopedSaveFixture(true); + let jo = addScopedSupport(fixture, "Jo", "save-m8-jo", "save-agreement-jo"); + retractScopedSupport(fixture, "Rob", "save-rob-withdraws"); + fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([fixture.proposal.source, jo.agreement.source]); + expect(saved.sources).not.toContain(fixture.agreement.source); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("scoped Save excludes a support source withdrawn after its agreement", async () => { + let fixture = scopedSaveFixture(true); + let { setup } = fixture; + let state = applyEvent(setup.plan.conversationPlan, { + id: "save-option-for-retraction", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1003, + contribution: { id: fixture.optionId, text: "Lexical", authoring: "human-edited" }, + }); + let retraction = entry("save-rob-retracts", "I oppose Lexical now."); + retraction.author = { kind: "member", handle: "Rob" }; + retraction.ts = 1004; + state = applyInference(state, { + id: "save-rob-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: retraction.ts, + source: { + messageId: retraction.id, + author: retraction.author, + quote: retraction.text, + start: 0, + end: retraction.text.length, + role: "objection", + }, + optionId: fixture.optionId, + position: "oppose", + }, retraction); + setup.plan.chat.entries.push(retraction); + setup.plan.conversationPlan = state; + await setup.dependencies.persist(); + let processor = createProcessor(setup.dependencies); + let saved = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let event = setup.durable.state.events.find(item => item.id === saved.eventId); + expect(event?.type).toBe("scoped-choice.saved"); + if (event?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(event.agreementId).toBeUndefined(); + expect(event.sources).toEqual([fixture.proposal.source]); + processor.stop(); +}); + +test("scoped Save keeps an agreement after an unlinked named-option objection", async () => { + let fixture = scopedSaveFixture(true); + let { setup } = fixture; + let state = applyEvent(setup.plan.conversationPlan, { + id: "save-option-for-unlinked-objection", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1003, + contribution: { id: fixture.optionId, text: "Lexical", authoring: "human-edited" }, + }); + let objection = entry("save-rob-unlinked-objection", "I oppose Lexical."); + objection.author = { kind: "member", handle: "Rob" }; + objection.ts = 1004; + state = applyInference(state, { + id: "save-rob-unlinked-opposes", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: objection.ts, + source: { + messageId: objection.id, + author: objection.author, + quote: objection.text, + start: 0, + end: objection.text.length, + role: "objection", + }, + optionId: fixture.optionId, + position: "oppose", + }, objection); + setup.plan.chat.entries.push(objection); + setup.plan.conversationPlan = state; + let processor = createProcessor(setup.dependencies); + let saved = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let event = setup.durable.state.events.find(item => item.id === saved.eventId); + expect(event?.type).toBe("scoped-choice.saved"); + if (event?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(event.agreementId).toBe(fixture.agreement.id); + expect(event.sources).toEqual([fixture.proposal.source, fixture.agreement.source]); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts new file mode 100644 index 00000000..6b67b314 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts @@ -0,0 +1,212 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; +import { entry, harness, opened } from "./service.test-fixtures"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export function scopedSaveFixture(withAgreement = false) { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("save-seed", "Which editor should we use?"); + let m6: Chat.Entry = { + ...entry( + "save-m6", + "agreed. I'd pick Lexical for the spike; still want to see how it handles pasted tables.", + ), + author: { kind: "member", handle: "Mei" }, + ts: 1001, + }; + let m7: Chat.Entry = { + ...entry("save-m7", "yep, Lexical for the spike. not a final library call yet."), + author: { kind: "member", handle: "Rob" }, + ts: 1002, + }; + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "save-link-spike", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + questionnaireId: cardId, + }); + let proposal: ConversationPlan.Event = { + id: "save-proposal-m6", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: m6.ts, + source: { + messageId: m6.id, + author: m6.author as ConversationPlan.SourceAuthor, + quote: "I'd pick Lexical for the spike;", + start: 8, + end: 39, + role: "support", + }, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyInference(state, proposal, m6); + let agreement: ConversationPlan.Event = { + id: "save-agreement-m7", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: m7.ts, + source: { + messageId: m7.id, + author: m7.author as ConversationPlan.SourceAuthor, + quote: "yep, Lexical for the spike.", + start: 0, + end: 27, + role: "support", + }, + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + if (withAgreement) state = applyInference(state, agreement, m7); + let setup = harness(); + setup.plan.chat.entries.push(seed, m6, ...(withAgreement ? [m7] : [])); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Lexical" }] }] }, + } as never); + return { setup, cardId, optionId, proposal, agreement, m6, m7 }; +} + +export function addScopedSupport( + fixture: ReturnType, + handle: string, + messageId: string, + agreementId: string, +) { + let message = entry(messageId, "yep, Lexical for the spike."); + message.author = { kind: "member", handle }; + message.ts = 1003; + let thread = fixture.setup.plan.conversationPlan.threads[0]!; + let agreement: ConversationPlan.Event = { + id: agreementId, + type: "scoped-choice.agreed", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "support", + }, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + label: "Lexical", + scope: "spike", + }; + fixture.setup.plan.conversationPlan = applyInference( + fixture.setup.plan.conversationPlan, + agreement, + message, + ); + fixture.setup.plan.chat.entries.push(message); + return { agreement, message }; +} + +export function retractScopedSupport( + fixture: ReturnType, + handle: string, + messageId: string, +) { + let message = entry(messageId, "I withdraw my support for Lexical for the spike."); + message.author = { kind: "member", handle }; + message.ts = 1004; + let thread = fixture.setup.plan.conversationPlan.threads[0]!; + let retraction: ConversationPlan.Event = { + id: `${messageId}:stance`, + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "withdrawal", + }, + position: "neutral", + }; + fixture.setup.plan.conversationPlan = applyInference( + fixture.setup.plan.conversationPlan, + retraction, + message, + ); + fixture.setup.plan.chat.entries.push(message); + return { retraction, message }; +} + +export type ScopedChoiceSaveInput = { + actionId: string; + threadId: string; + expectedVersion: number; + proposalId: string; + cardId: string; + optionId: string; + expectedLabel: string; + expectedGeneration: number; +}; + +export async function saveScopedChoice( + processor: ReturnType, + input: ScopedChoiceSaveInput, + actor = { kind: "member", handle: "Rob" } as Extract, +) { + let saver = processor as unknown as { + saveScopedChoice( + input: ScopedChoiceSaveInput, + actor: Extract, + ): Promise<{ eventId: string; revision: number }>; + }; + return saver.saveScopedChoice(input, actor); +} + +export function scopedChoiceSaveInput( + fixture: ReturnType, + actionId = "save-spike-choice", +): ScopedChoiceSaveInput { + return { + actionId, + threadId: "thread-a", + expectedVersion: fixture.setup.plan.conversationPlan.threads[0]!.version, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + expectedLabel: "Lexical", + expectedGeneration: 0, + }; +} diff --git a/apps/server/src/conversation-plan/service.test-fixtures.ts b/apps/server/src/conversation-plan/service.test-fixtures.ts new file mode 100644 index 00000000..38e45875 --- /dev/null +++ b/apps/server/src/conversation-plan/service.test-fixtures.ts @@ -0,0 +1,146 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; + +import type { Effect } from "./effects"; +import type { PendingCardAction } from "../questions/card-actions"; +import type { Dependencies } from "./service"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export type PlanData = Dependencies["plan"]; + +export function entry(id: string, text: string): Chat.Entry { + return { id, text, ts: 1000, author: { kind: "member", handle: "ana" } }; +} + +export function opened(message: Chat.Entry): ConversationPlan.Event { + return { + id: `open:${message.id}`, + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + type: "thread.opened", + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; +} + +export function unlinked(): Awaited>> { + return { + events: [], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "unlinked", + passes: [], + }, + }; +} + +export function deferred() { + let resolve!: (value: T) => void; + let promise = new Promise(done => resolve = done); + return { promise, resolve }; +} + +export 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"); +} + +export function harness( + interpret: NonNullable = async () => unlinked(), +) { + let plan = { + id: "channel", + chat: { entries: [] as Chat.Entry[] }, + conversationPlan: initialState(), + conversationPlanRetries: [] as Array<{ id: string; messageId: string }>, + conversationPlanEffects: [] as string[], + conversationPlanPendingEffects: [] as Effect[], + pendingCardActions: [] as PendingCardAction[], + records: new Map(), + } as PlanData; + let durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + retries: plan.conversationPlanRetries, + receipts: plan.conversationPlanEffects, + pending: plan.conversationPlanPendingEffects, + pendingCards: plan.pendingCardActions, + records: plan.records, + }); + let publications: ConversationPlan.State[] = []; + let active = true; + let failNext = false; + let locked = false; + let tail = Promise.resolve(); + let errors: unknown[] = []; + let dependencies: Dependencies = { + plan, + exclusive: action => { + let operation = tail.then(async () => { + locked = true; + try { + return await action(); + } finally { + locked = false; + } + }); + tail = operation.then(() => {}, () => {}); + return operation; + }, + persist: async () => { + if (failNext) { + failNext = false; + throw new Error("storage failed"); + } + durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + retries: plan.conversationPlanRetries, + receipts: plan.conversationPlanEffects, + pending: plan.conversationPlanPendingEffects, + pendingCards: plan.pendingCardActions, + records: plan.records, + }); + }, + publish: state => { + expect(durable.state).toEqual(state); + publications.push(structuredClone(state)); + }, + active: () => active, + interpret: async (input, signal) => { + expect(locked).toBe(false); + return interpret(input, signal); + }, + onError: error => errors.push(error), + }; + return { + plan, + dependencies, + get durable() { + return durable; + }, + publications, + errors, + set active(value: boolean) { + active = value; + }, + fail() { + failNext = true; + }, + }; +} diff --git a/apps/server/src/conversation-plan/service.ts b/apps/server/src/conversation-plan/service.ts new file mode 100644 index 00000000..a7d08807 --- /dev/null +++ b/apps/server/src/conversation-plan/service.ts @@ -0,0 +1,115 @@ +import type { Dependencies } from "./processor-types"; +export { MAX_PENDING_EFFECTS } from "./processor-fields"; +export { researchQuoteFocus, researchQuoteSupportsPair } from "./processor-research-quotes"; +export type { Dependencies, EffectCommands } from "./processor-types"; +import { createOutbox } from "./processor-outbox"; +import { createCommands } from "./processor-commands"; +import { createScopedSave } from "./processor-scoped-save"; +import { createResearchConsent } from "./processor-research-consent"; +import { createRun } from "./processor-run"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export type Processor = ReturnType; + +export function createProcessor(deps: Dependencies) { + let { plan } = deps; + let stopped = false; + let controller = new AbortController(); + let running: Promise | undefined; + let commands = new Set>(); + let wakeRequested = false; + let effectCommands = deps.effects; + let report = deps.onError ?? ((error: unknown) => console.error("[conversation-plan]", error)); + let active = () => !stopped && deps.active(); + let publish = () => { + try { + deps.publish(plan.conversationPlan); + } catch (error) { + report(error); + } + }; + let { markApplied, recover, drainEffects, setEffects } = createOutbox( + deps, + plan, + active, + wake, + effectCommands, + ); + let { accept, afterMessage, correct, record, retry } = createCommands( + deps, + plan, + active, + publish, + wake, + ); + let saveScopedChoice = createScopedSave(deps, plan, active, publish); + let researchConsent = createResearchConsent(deps, plan, active, publish, markApplied, report); + let run = createRun(deps, plan, active, controller, publish, recover, drainEffects); + + function wake(): void { + if (!active()) return; + if (running) { + wakeRequested = true; + return; + } + let blocked = false; + let waitingForMirror = false; + running = Promise.resolve().then(() => run()).then(waiting => { + waitingForMirror = waiting; + }).catch(error => { + blocked = true; + report(error); + }).finally(() => { + running = undefined; + let requested = wakeRequested; + wakeRequested = false; + if ( + !blocked && active() + && ( + requested + || !waitingForMirror + && plan.conversationPlan.queue.some(item => item.status === "pending") + ) + ) wake(); + }); + } + + function stop(): void { + stopped = true; + controller.abort(); + } + + function track(action: (...args: Args) => Promise) { + return (...args: Args): Promise => { + let task = action(...args); + commands.add(task); + let settled = () => commands.delete(task); + void task.then(settled, settled); + return task; + }; + } + + /** Drain admitted commands and effects before closing their document. */ + async function idle(): Promise { + let tasks = [...commands, ...(running ? [running] : [])]; + while (tasks.length > 0) { + await Promise.allSettled(tasks); + tasks = [...commands, ...(running ? [running] : [])]; + } + } + + return { + accept: track(accept), + afterMessage, + correct: track(correct), + saveScopedChoice: track(saveScopedChoice), + researchConsent: track(researchConsent), + record: track(record), + retry: track(retry), + setEffects, + wake, + stop, + idle, + }; +} diff --git a/apps/server/src/conversation-plan/sources.test.ts b/apps/server/src/conversation-plan/sources.test.ts new file mode 100644 index 00000000..f56b4922 --- /dev/null +++ b/apps/server/src/conversation-plan/sources.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { assertSourceShape, validateSource } from "./sources"; + +function message(): Chat.Entry { + return { id: "message-1", author: { kind: "member", handle: "maggie" }, text: "🧪 Bun", ts: 1 }; +} +function source(): ConversationPlan.SourceRef { + return { + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote: "Bun", + start: 3, + end: 6, + role: "option", + }; +} + +test("a source preserves an exact UTF-16 span and member attribution in a saved entry", () => { + let saved = message(); + let citation = source(); + expect(() => validateSource(citation, saved)).not.toThrow(); + expect(citation).toEqual({ + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote: "Bun", + start: 3, + end: 6, + role: "option", + }); + expect(() => validateSource({ ...citation, quote: "🧪", start: 0, end: 2 }, saved)).not.toThrow(); +}); + +test("an agent source must cite a complete saved agent entry", () => { + let saved: Chat.Entry = { ...message(), author: { kind: "agent" } }; + let citation: ConversationPlan.SourceRef = { ...source(), author: { kind: "agent" } }; + expect(() => validateSource(citation, saved)).not.toThrow(); + expect(() => validateSource(citation, { ...saved, streaming: true })) + .toThrow(/does not match saved message/); + expect(() => validateSource(citation, { ...saved, streaming: false })).not.toThrow(); +}); + +test("saved message identity, text, and attribution must all match the source", () => { + let saved = message(); + for ( + let citation of [ + { ...source(), messageId: "another-message" }, + { ...source(), quote: "Deno", end: 7 }, + { ...source(), author: { kind: "agent" as const } }, + { ...source(), author: { kind: "member" as const, handle: "spoofed" } }, + { ...source(), start: 4, end: 7 }, + ] + ) expect(() => validateSource(citation, saved)).toThrow(/does not match saved message/); + expect(() => validateSource(source(), { ...saved, streaming: true })) + .toThrow(/does not match saved message/); + expect(() => validateSource(source(), { ...saved, author: { kind: "system" } })) + .toThrow(/does not match saved message/); +}); + +test("source shape bounds message IDs, quotes, and safe UTF-16 offsets", () => { + expect(() => + assertSourceShape({ + ...source(), + messageId: "x".repeat(200), + quote: "x".repeat(2048), + start: 0, + end: 2048, + }) + ) + .not.toThrow(); + for ( + let override of [ + { messageId: "" }, + { messageId: "x".repeat(201) }, + { messageId: 1 }, + { quote: "" }, + { quote: "x".repeat(2049), start: 0, end: 2049 }, + { quote: 1 }, + { start: -1, end: 2 }, + { start: 3.5, end: 6.5 }, + { start: 3, end: 3 }, + { start: 6, end: 3 }, + { end: 7 }, + { start: Number.NaN }, + { start: Number.MAX_SAFE_INTEGER + 1, end: Number.MAX_SAFE_INTEGER + 4 }, + ] + ) { + expect(() => assertSourceShape({ ...source(), ...override })).toThrow( + /invalid conversation source/, + ); + } +}); + +test("source shape rejects missing fields and unknown source or author fields", () => { + for (let field of ["messageId", "author", "quote", "start", "end", "role"]) { + let value: Record = { ...source() }; + delete value[field]; + expect(() => assertSourceShape(value)).toThrow(/invalid conversation source/); + } + expect(() => assertSourceShape({ ...source(), classifierText: "Bun" })) + .toThrow(/unknown conversation source field/); + expect(() => + assertSourceShape({ ...source(), author: { kind: "member", handle: "maggie", extra: true } }) + ) + .toThrow(/unknown conversation source author field/); + expect(() => assertSourceShape({ ...source(), author: { kind: "agent", handle: "maggie" } })) + .toThrow(/unknown conversation source author field/); +}); + +test("source roles and authors follow the archived allowlist", () => { + let roles: ConversationPlan.SourceRole[] = [ + "question", + "option", + "reason", + "constraint", + "support", + "objection", + "withdrawal", + "verification", + "resolution", + "reopening", + ]; + for (let role of roles) expect(() => assertSourceShape({ ...source(), role })).not.toThrow(); + for ( + let value of [ + { ...source(), role: "decision" }, + { ...source(), author: { kind: "system" } }, + { ...source(), author: { kind: "classifier" } }, + { ...source(), author: { kind: "member", handle: "" } }, + { ...source(), author: { kind: "member", handle: 1 } }, + null, + [], + ] + ) expect(() => assertSourceShape(value)).toThrow(/invalid conversation source/); +}); diff --git a/apps/server/src/conversation-plan/sources.ts b/apps/server/src/conversation-plan/sources.ts new file mode 100644 index 00000000..7d01192d --- /dev/null +++ b/apps/server/src/conversation-plan/sources.ts @@ -0,0 +1,67 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +const ROLES = new Set([ + "question", + "option", + "reason", + "constraint", + "support", + "objection", + "withdrawal", + "verification", + "resolution", + "reopening", +]); + +export function assertSourceShape(value: unknown): asserts value is ConversationPlan.SourceRef { + if (!value || typeof value !== "object") throw new Error("invalid conversation source"); + let source = value as Record; + if ( + Object.keys(source).some((key) => + ![ + "messageId", + "author", + "quote", + "start", + "end", + "role", + ].includes(key) + ) + ) throw new Error("unknown conversation source field"); + let author = source.author; + if ( + typeof source.messageId !== "string" || !source.messageId || source.messageId.length > 200 + || typeof source.quote !== "string" || !source.quote || source.quote.length > 2048 + || !Number.isSafeInteger(source.start) || !Number.isSafeInteger(source.end) + || (source.start as number) < 0 || (source.end as number) <= (source.start as number) + || (source.end as number) - (source.start as number) !== source.quote.length + || !ROLES.has(source.role as ConversationPlan.SourceRole) + || !author || typeof author !== "object" + ) throw new Error("invalid conversation source"); + let claimed = author as Record; + if ( + Object.keys(claimed).some((key) => + ![ + "kind", + ...(claimed.kind === "member" ? ["handle"] : []), + ].includes(key) + ) + ) throw new Error("unknown conversation source author field"); + if ( + !((claimed.kind === "member" && typeof claimed.handle === "string" && claimed.handle.length > 0) + || claimed.kind === "agent") + ) throw new Error("invalid conversation source author"); +} + +/** Validates against the saved Chat entry, not candidate text supplied by the classifier. */ +export function validateSource(source: ConversationPlan.SourceRef, message: Chat.Entry): void { + assertSourceShape(source); + if ( + message.id !== source.messageId + || message.streaming + || message.author.kind !== source.author.kind + || (message.author.kind === "member" && source.author.kind === "member" + && message.author.handle !== source.author.handle) + || message.text.slice(source.start, source.end) !== source.quote + ) throw new Error("conversation source quote does not match saved message"); +} diff --git a/apps/server/src/conversation-plan/state-provenance.ts b/apps/server/src/conversation-plan/state-provenance.ts new file mode 100644 index 00000000..e4e5e742 --- /dev/null +++ b/apps/server/src/conversation-plan/state-provenance.ts @@ -0,0 +1,53 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { validateSource } from "./sources"; +import { taskLabelChanges } from "./research-snapshots"; + +type State = ConversationPlan.State; + +function eventSecond(at: number): number { + return Math.floor(at >= 100_000_000_000 ? at / 1_000 : at); +} + +export function validateStateSourcesWithChanges( + state: State, + messages: ReadonlyMap | readonly Chat.Entry[], + labelChanges: ReadonlyMap, +): void { + let lookup = Array.isArray(messages) + ? new Map(messages.map((message) => [message.id, message])) + : messages as ReadonlyMap; + for (let event of state.events) { + if ("source" in event && event.source) { + let message = lookup.get(event.source.messageId); + if (!message) throw new Error("conversation source message missing"); + validateSource(event.source, message); + } + if (event.type === "scoped-choice.saved") { + for (let source of event.sources) { + let message = lookup.get(source.messageId); + if (!message) throw new Error("conversation source message missing"); + validateSource(source, message); + } + } + } + for (let offer of state.researchOffers ?? []) { + let message = lookup.get(offer.source.messageId); + if (!message) throw new Error("research offer source message missing"); + validateSource({ ...offer.source, role: "support" }, message); + if ( + offer.task + && (labelChanges.get(offer.id) ?? []).some(index => + index >= offer.task!.observedEventCount + && eventSecond(state.events[index].at) <= message.ts + ) + ) throw new Error("research task source postdates its captured event prefix"); + } +} + +export function validateStateSources( + state: State, + messages: ReadonlyMap | readonly Chat.Entry[], +): void { + let taskOffers = (state.researchOffers ?? []).filter(offer => offer.task); + validateStateSourcesWithChanges(state, messages, taskLabelChanges(state.events, taskOffers)); +} diff --git a/apps/server/src/conversation-plan/state-restoration.ts b/apps/server/src/conversation-plan/state-restoration.ts new file mode 100644 index 00000000..dc0278f0 --- /dev/null +++ b/apps/server/src/conversation-plan/state-restoration.ts @@ -0,0 +1,72 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { isDeepStrictEqual } from "node:util"; +import { initialState } from "./domain-initial"; +import { applyEvent, currentScopedProposal } from "./events"; +import { assertStateShape } from "./validation"; +import { namesStaleResearchOption, taskMatches, taskSnapshot } from "./research-snapshots"; +import { validateStateSourcesWithChanges } from "./state-provenance"; + +type State = ConversationPlan.State; + +export function restoreState( + raw: unknown, + messages?: ReadonlyMap | readonly Chat.Entry[], +): State { + if (raw === undefined) return initialState(); + assertStateShape(raw); + let tasks = (raw.researchOffers ?? []).filter(offer => offer.task); + let captures = new Map(); + for (let offer of tasks) { + let count = offer.task!.observedEventCount; + captures.set(count, [...(captures.get(count) ?? []), offer]); + } + let matched = new Set(); + let rebuilt = initialState(); + let changes = new Map(); + let checkCapture = (count: number) => { + for (let offer of captures.get(count) ?? []) { + if (offer.task && taskMatches(rebuilt, offer.task)) matched.add(offer.id); + } + }; + checkCapture(0); + for (let [eventIndex, event] of raw.events.entries()) { + let before = tasks.map(offer => taskSnapshot(rebuilt, offer.task!)); + rebuilt = applyEvent(rebuilt, event); + for (let [index, offer] of tasks.entries()) { + if (!isDeepStrictEqual(before[index], taskSnapshot(rebuilt, offer.task!))) { + changes.set(offer.id, [...(changes.get(offer.id) ?? []), eventIndex]); + } + } + checkCapture(rebuilt.events.length); + } + let restored = structuredClone(raw); + for (let thread of restored.threads) { + let pending = thread.pendingScopedChoice; + if (!pending || pending.proposalId !== undefined) continue; + let proposal = currentScopedProposal(thread, restored.events); + if (!proposal) throw new Error("scoped choice proposal is ambiguous or missing"); + pending.proposalId = proposal.id; + } + if ( + !isDeepStrictEqual( + JSON.parse(JSON.stringify(rebuilt.threads)), + JSON.parse(JSON.stringify(restored.threads)), + ) + || rebuilt.events.length !== raw.events.length || raw.revision < rebuilt.revision + ) { + throw new Error("conversation plan snapshot does not match its events"); + } + if (matched.size !== tasks.length) { + throw new Error("research task capture does not match history"); + } + if ( + tasks.some(offer => + offer.task + && namesStaleResearchOption(raw, offer.task, offer.source.quote) + ) + ) { + throw new Error("research task source names a stale option"); + } + if (messages) validateStateSourcesWithChanges(restored, messages, changes); + return restored; +} diff --git a/apps/server/src/conversation-plan/state-validation.test.ts b/apps/server/src/conversation-plan/state-validation.test.ts new file mode 100644 index 00000000..e49169cc --- /dev/null +++ b/apps/server/src/conversation-plan/state-validation.test.ts @@ -0,0 +1,237 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { + assertStateShape, + MAX_ANALYSIS, + MAX_EVENTS, + MAX_QUEUE, + MAX_RESEARCH_OFFERS, + MAX_THREADS, +} from "./validation"; + +function state(): ConversationPlan.State { + return { schemaVersion: 1, revision: 0, events: [], threads: [], queue: [], analysis: [] }; +} + +test("the empty version-one snapshot validates without adding stored fields", () => { + let snapshot = state(); + let before = structuredClone(snapshot); + expect(() => assertStateShape(snapshot)).not.toThrow(); + expect(snapshot).toEqual(before); + expect(() => assertStateShape({ ...snapshot, schemaVersion: 2 })).toThrow( + "unsupported conversation plan schema", + ); +}); + +function analysis(): ConversationPlan.AnalysisRecord { + return { + messageId: "message-1", + questionSetVersion: "questions-1", + modelVersion: "fixture", + status: "applied", + passes: [], + eventIds: [], + }; +} +function withAnswer(answer: unknown) { + return { + ...state(), + analysis: [{ ...analysis(), passes: [{ stage: "triage", answers: { flag: answer } }] }], + }; +} + +test("snapshot array bounds and revision reject malformed saved containers", () => { + for ( + let [field, maximum] of [ + ["events", MAX_EVENTS], + ["threads", MAX_THREADS], + ["queue", MAX_QUEUE], + ["analysis", MAX_ANALYSIS], + ] as const + ) { + expect(() => + assertStateShape({ ...state(), [field]: Array.from({ length: maximum + 1 }, () => null) }) + ) + .toThrow("invalid conversation plan snapshot arrays"); + expect(() => assertStateShape({ ...state(), [field]: {} })).toThrow( + "invalid conversation plan snapshot arrays", + ); + } + expect(() => + assertStateShape({ + ...state(), + researchOffers: Array.from({ length: MAX_RESEARCH_OFFERS + 1 }, () => null), + }) + ) + .toThrow("invalid research offers array"); + expect(() => assertStateShape({ ...state(), revision: -1 })).toThrow(/version/); + expect(() => assertStateShape({ ...state(), extra: true })).toThrow(/unknown/); +}); + +test("queued messages retain unique IDs and bounded attempts and errors", () => { + let queued = { messageId: "message-1", status: "pending", attempts: 0 }; + expect(() => assertStateShape({ ...state(), queue: [queued] })).not.toThrow(); + expect(() => assertStateShape({ ...state(), queue: [queued, queued] })).toThrow( + "duplicate queued message", + ); + for ( + let fields of [{ status: "unknown" }, { attempts: -1 }, { error: "x".repeat(301) }, { + extra: true, + }] + ) { + expect(() => assertStateShape({ ...state(), queue: [{ ...queued, ...fields }] })).toThrow(); + } +}); + +test("raw analysis probabilities, winning choices, and weighted scores validate", () => { + let choice = { + type: "choice", + choice: "save", + confidence: 0.8, + probabilities: { save: 0.8, skip: 0.2 }, + }; + let score = { + type: "score", + score: 0.75, + confidence: 0.9, + legend: { "0": "Low", "1": "High" }, + probabilities: { "0": 0.25, "1": 0.75 }, + }; + for (let answer of [{ type: "noul", noul: 0.5 }, choice, score]) { + expect(() => assertStateShape(withAnswer(answer))).not.toThrow(); + } + for ( + let answer of [ + { type: "noul", noul: Number.NaN }, + { type: "noul", noul: 1.1 }, + { ...choice, choice: "skip" }, + { ...choice, probabilities: { save: 0.1, skip: 0.2 } }, + { ...choice, probabilities: { save: 1 } }, + { ...choice, confidence: -1 }, + { ...score, score: 0.1 }, + { ...score, legend: { "1": "Low", "2": "High" } }, + { ...score, probabilities: { "0": 0.25, "2": 0.75 } }, + { type: "noul", noul: 0.5, extra: true }, + ] + ) expect(() => assertStateShape(withAnswer(answer))).toThrow(); +}); + +test("analysis pass order, question counts, and aggregate distributions stay bounded", () => { + let passes = [ + { stage: "triage", answers: {} }, + { stage: "targeting", answers: {} }, + { stage: "clarification", version: "bare-editor-clarification-1", answers: {} }, + ]; + expect(() => assertStateShape({ ...state(), analysis: [{ ...analysis(), passes }] })).not + .toThrow(); + expect(() => assertStateShape({ ...state(), analysis: [{ ...analysis(), passes: [passes[2]] }] })) + .toThrow(/clarification/); + let answers = Object.fromEntries( + Array.from({ length: 46 }, (_, index) => [`q${index}`, { type: "noul", noul: 0.5 }]), + ); + expect(() => + assertStateShape({ + ...state(), + analysis: [{ ...analysis(), passes: [{ stage: "triage", answers }] }], + }) + ).toThrow("too many analysis questions"); + let probabilities = Object.fromEntries( + Array.from({ length: 200 }, (_, index) => [`option${index}`, 0.005]), + ); + let manyAnswers = Object.fromEntries( + Array.from( + { length: 3 }, + ( + _, + index, + ) => [`q${index}`, { type: "choice", choice: "option0", confidence: 0.005, probabilities }], + ), + ); + expect(() => + assertStateShape({ + ...state(), + analysis: [{ ...analysis(), passes: [{ stage: "triage", answers: manyAnswers }] }], + }) + ).toThrow("too many analysis answer entries"); +}); + +test("candidate outcome event IDs must belong to the analysis record", () => { + let record = { + ...analysis(), + eventIds: ["event-1"], + outcomes: [{ start: 0, end: 3, status: "accepted", gate: "fixture", eventIds: ["event-1"] }], + }; + expect(() => assertStateShape({ ...state(), analysis: [record] })).not.toThrow(); + expect(() => assertStateShape({ ...state(), analysis: [{ ...record, eventIds: [] }] })) + .toThrow("candidate outcome event is not accepted"); + expect(() => + assertStateShape({ + ...state(), + analysis: [{ ...record, outcomes: [{ ...record.outcomes[0], end: 0 }] }], + }) + ) + .toThrow("invalid candidate outcome"); + expect(() => + assertStateShape({ + ...state(), + analysis: [{ ...analysis(), quoteValidation: [{ start: 0, end: 3, valid: "yes" }] }], + }) + ) + .toThrow("invalid quote validation debug"); +}); + +function offer(index: number): ConversationPlan.ResearchOffer { + return { + id: `offer-${index}`, + needId: `need-${index}`, + contextId: "context-1", + source: { + messageId: `message-${index}`, + author: { kind: "member", handle: "maggie" }, + quote: "Costs", + start: 0, + end: 5, + }, + brief: "Costs", + status: "offered", + }; +} + +test("snapshot research identity guards duplicate offers, sources, needs, and actions", () => { + let first = offer(1); + let second = offer(2); + expect(() => assertStateShape({ ...state(), researchOffers: [first, second] })).not.toThrow(); + for ( + let changed of [ + { ...second, id: first.id }, + { ...second, source: first.source }, + { ...second, needId: first.needId }, + ] + ) { + expect(() => assertStateShape({ ...state(), researchOffers: [first, changed] })).toThrow( + "duplicate research offer identity", + ); + } + let action: ConversationPlan.ResearchAction = { + id: "action-1", + kind: "research", + actor: { kind: "member", handle: "maggie" }, + principalId: "principal-1", + at: 1, + }; + expect(() => + assertStateShape({ + ...state(), + researchOffers: [{ ...first, status: "accepted", action }, { + ...second, + status: "accepted", + action, + }], + }) + ) + .toThrow("duplicate research offer identity"); + expect(() => + assertStateShape({ ...state(), researchOffers: [{ ...first, threadId: "missing" }] }) + ) + .toThrow("research offer thread is missing"); +}); diff --git a/apps/server/src/conversation-plan/state-validation.ts b/apps/server/src/conversation-plan/state-validation.ts new file mode 100644 index 00000000..61619286 --- /dev/null +++ b/apps/server/src/conversation-plan/state-validation.ts @@ -0,0 +1,265 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { assertEventShape } from "./event-validation"; +import { assertResearchOfferShape } from "./research-validation"; +import { + id, + knownKeys, + MAX_ANALYSIS, + MAX_EVENTS, + MAX_QUEUE, + MAX_RESEARCH_OFFERS, + MAX_THREADS, + record, + version, +} from "./validation-fields"; + +function unitProbability(value: unknown): void { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { + throw new Error("invalid analysis probability"); + } +} + +function analysisDistribution(value: unknown, max: number): Record { + let distribution = record(value); + let keys = Object.keys(distribution); + if (keys.length < 2 || keys.length > max) throw new Error("invalid analysis distribution"); + for (let key of keys) { + id(key); + unitProbability(distribution[key]); + } + let total = Object.values(distribution).reduce((sum: number, item) => sum + (item as number), 0); + if (Math.abs(total - 1) > 0.08) throw new Error("invalid analysis distribution"); + return distribution as Record; +} + +export function assertStateShape(value: unknown): asserts value is ConversationPlan.State { + let state = record(value); + knownKeys(state, [ + "schemaVersion", + "revision", + "events", + "threads", + "queue", + "analysis", + "researchOffers", + ]); + if (state.schemaVersion !== 1) throw new Error("unsupported conversation plan schema"); + version(state.revision); + if ( + !Array.isArray(state.events) || state.events.length > MAX_EVENTS + || !Array.isArray(state.threads) || state.threads.length > MAX_THREADS + || !Array.isArray(state.queue) || state.queue.length > MAX_QUEUE + || !Array.isArray(state.analysis) || state.analysis.length > MAX_ANALYSIS + ) throw new Error("invalid conversation plan snapshot arrays"); + if (state.researchOffers !== undefined) { + if (!Array.isArray(state.researchOffers) || state.researchOffers.length > MAX_RESEARCH_OFFERS) { + throw new Error("invalid research offers array"); + } + let offerIds = new Set(); + let sources = new Set(); + let needs = new Set(); + let actionIds = new Set(); + let threadIds = new Set(state.threads.map((item: ConversationPlan.Thread) => item.id)); + for (let offer of state.researchOffers) { + assertResearchOfferShape(offer); + let need = JSON.stringify([offer.needId, offer.contextId]); + if ( + offerIds.has(offer.id) || sources.has(offer.source.messageId) || needs.has(need) + || offer.action && actionIds.has(offer.action.id) + ) throw new Error("duplicate research offer identity"); + if (offer.threadId !== undefined && !threadIds.has(offer.threadId)) { + throw new Error("research offer thread is missing"); + } + offerIds.add(offer.id); + sources.add(offer.source.messageId); + needs.add(need); + if (offer.action) actionIds.add(offer.action.id); + } + } + for (let event of state.events) assertEventShape(event); + for (let item of state.queue) { + let queued = record(item); + knownKeys(queued, ["messageId", "status", "attempts", "error"]); + id(queued.messageId); + if (!["pending", "processing", "failed"].includes(queued.status as string)) { + throw new Error("invalid queue status"); + } + version(queued.attempts); + if ( + queued.error !== undefined && (typeof queued.error !== "string" || queued.error.length > 300) + ) throw new Error("invalid queue error"); + } + if (new Set(state.queue.map((item) => item.messageId)).size !== state.queue.length) { + throw new Error("duplicate queued message"); + } + for (let item of state.analysis) { + let analysis = record(item); + knownKeys(analysis, [ + "messageId", + "questionSetVersion", + "modelVersion", + "status", + "passes", + "selectedTarget", + "quoteValidation", + "outcomes", + "policyGate", + "candidates", + "eventIds", + "latencyMs", + "error", + ]); + id(analysis.messageId); + id(analysis.questionSetVersion); + id(analysis.modelVersion); + if ( + !["queued", "running", "applied", "unlinked", "failed"].includes(analysis.status as string) + ) throw new Error("invalid analysis status"); + if (!Array.isArray(analysis.passes) || analysis.passes.length > 3) { + throw new Error("invalid analysis passes"); + } + for (let [index, pass] of analysis.passes.entries()) { + let p = record(pass); + knownKeys(p, ["stage", "version", "answers"]); + if (!["triage", "targeting", "clarification"].includes(p.stage as string)) { + throw new Error("invalid analysis pass stage"); + } + if (p.stage === "clarification") { + if ( + index !== 2 || analysis.passes[0]?.stage !== "triage" + || analysis.passes[1]?.stage !== "targeting" + || p.version !== "bare-editor-clarification-1" + ) throw new Error("invalid clarification pass version or order"); + } else if (p.version !== undefined) { + throw new Error("invalid analysis pass version"); + } + let answers = record(p.answers); + if (Object.keys(answers).length > 45) throw new Error("too many analysis questions"); + let answerEntries = 0; + for (let [question, value] of Object.entries(answers)) { + id(question); + let answer = record(value); + if (answer.type === "noul") { + knownKeys(answer, ["type", "noul"]); + unitProbability(answer.noul); + continue; + } + if (answer.type === "choice") { + knownKeys(answer, ["type", "choice", "confidence", "probabilities"]); + id(answer.choice); + unitProbability(answer.confidence); + let distribution = analysisDistribution(answer.probabilities, 255); + answerEntries += Object.keys(distribution).length; + if ( + !(answer.choice as string in distribution) + || distribution[answer.choice as string] + 0.01 + < Math.max(...Object.values(distribution)) + ) { + throw new Error("invalid analysis choice"); + } + continue; + } + if (answer.type === "score") { + knownKeys(answer, ["type", "score", "confidence", "legend", "probabilities"]); + unitProbability(answer.confidence); + let legend = record(answer.legend); + let levels = Object.keys(legend); + if ( + levels.length < 2 || levels.length > 10 + || levels.some((level, index) => + level !== String(index) || typeof legend[level] !== "string" + || !(legend[level] as string).trim() || (legend[level] as string).length > 500 + ) + ) throw new Error("invalid analysis score legend"); + let distribution = analysisDistribution(answer.probabilities, 10); + answerEntries += Object.keys(distribution).length; + if ( + Object.keys(distribution).length !== levels.length + || levels.some((level) => !(level in distribution)) + ) { + throw new Error("invalid analysis score distribution"); + } + let weighted = levels.reduce((sum, level, index) => sum + index * distribution[level], 0); + if ( + typeof answer.score !== "number" || !Number.isFinite(answer.score) + || answer.score < 0 || answer.score > levels.length - 1 + || Math.abs(answer.score - weighted) > 0.12 + ) { + throw new Error("invalid analysis score"); + } + continue; + } + throw new Error("invalid analysis answer type"); + } + if (answerEntries > 512) throw new Error("too many analysis answer entries"); + } + if (!Array.isArray(analysis.eventIds) || analysis.eventIds.length > 12) { + throw new Error("invalid analysis event IDs"); + } + for (let eventId of analysis.eventIds) id(eventId); + if (analysis.outcomes !== undefined) { + if (!Array.isArray(analysis.outcomes) || analysis.outcomes.length > 4) { + throw new Error("invalid candidate outcomes"); + } + for (let outcome of analysis.outcomes) { + let item = record(outcome); + knownKeys(item, ["start", "end", "status", "gate", "targetId", "eventIds"]); + version(item.start); + version(item.end); + if ( + (item.end as number) <= (item.start as number) + || !["accepted", "review", "ignored"].includes(item.status as string) + || typeof item.gate !== "string" || !item.gate || item.gate.length > 300 + || !Array.isArray(item.eventIds) || item.eventIds.length > 12 + ) { + throw new Error("invalid candidate outcome"); + } + if (item.targetId !== undefined) id(item.targetId); + for (let eventId of item.eventIds) { + id(eventId); + if (!analysis.eventIds.includes(eventId)) { + throw new Error("candidate outcome event is not accepted"); + } + } + } + } + if ( + analysis.error !== undefined + && (typeof analysis.error !== "string" || analysis.error.length > 300) + ) throw new Error("invalid analysis error"); + if (analysis.latencyMs !== undefined) version(analysis.latencyMs); + if (analysis.selectedTarget !== undefined) id(analysis.selectedTarget); + if ( + analysis.policyGate !== undefined + && (typeof analysis.policyGate !== "string" || analysis.policyGate.length > 300) + ) throw new Error("invalid analysis policy gate"); + if (analysis.candidates !== undefined) { + if (!Array.isArray(analysis.candidates) || analysis.candidates.length > 12) { + throw new Error("invalid analysis candidates"); + } + for (let candidate of analysis.candidates) { + let c = record(candidate); + knownKeys(c, ["id", "kind", "targetId"]); + id(c.id); + if (!["resolution", "reopening"].includes(c.kind as string)) { + throw new Error("invalid analysis candidate kind"); + } + if (c.targetId !== undefined) id(c.targetId); + } + } + if (analysis.quoteValidation !== undefined) { + if (!Array.isArray(analysis.quoteValidation) || analysis.quoteValidation.length > 12) { + throw new Error("invalid quote validation debug"); + } + for (let quote of analysis.quoteValidation) { + let q = record(quote); + knownKeys(q, ["start", "end", "valid"]); + version(q.start); + version(q.end); + if ((q.end as number) <= (q.start as number) || typeof q.valid !== "boolean") { + throw new Error("invalid quote validation debug"); + } + } + } + } +} diff --git a/apps/server/src/conversation-plan/validation-fields.ts b/apps/server/src/conversation-plan/validation-fields.ts new file mode 100644 index 00000000..a5422a59 --- /dev/null +++ b/apps/server/src/conversation-plan/validation-fields.ts @@ -0,0 +1,54 @@ +export const MAX_CONTRIBUTIONS = 64; +export const MAX_THREADS = 20; +export const MAX_EVENTS = 4096; +export const MAX_ANALYSIS = 64; +export const MAX_QUEUE = 128; +export const MAX_RESEARCH_OFFERS = 64; + +export function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid conversation plan record"); + } + return value as Record; +} + +export function knownKeys(value: Record, allowed: readonly string[]): void { + if (Object.keys(value).some((key) => !allowed.includes(key))) { + throw new Error("unknown conversation plan field"); + } +} + +export function id(value: unknown): void { + if (typeof value !== "string" || !value || value.length > 200) { + throw new Error("invalid conversation plan ID"); + } +} + +export function text(value: unknown, max = 500): void { + if (typeof value !== "string" || !value.trim() || value.length > max) { + throw new Error("invalid conversation plan text"); + } +} + +/** Only an owned, direct preference with explicit provisional scope can propose a spike choice. */ +export function spikePreferenceLabel(quote: string): string | undefined { + return /^I(?:['’]d| would) pick (.+?) for (?:the|a) spike[;.!]?$/iu.exec(quote.trim())?.[1]; +} + +export function spikeAgreementLabel(quote: string): string | undefined { + return /^yep,\s+(.+?) for (?:the|a) spike[.!]$/iu.exec(quote.trim())?.[1]; +} + +export function version(value: unknown): void { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error("invalid conversation plan version"); + } +} + +export function actor(value: unknown): void { + let a = record(value); + knownKeys(a, a.kind === "member" ? ["kind", "handle"] : ["kind"]); + if (a.kind === "classifier" || a.kind === "agent") return; + if (a.kind === "member") return id(a.handle); + throw new Error("invalid conversation plan actor"); +} diff --git a/apps/server/src/conversation-plan/validation.test.ts b/apps/server/src/conversation-plan/validation.test.ts new file mode 100644 index 00000000..b9a4a4da --- /dev/null +++ b/apps/server/src/conversation-plan/validation.test.ts @@ -0,0 +1,342 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { + assertCorrectionAction, + assertEventShape, + MAX_ANALYSIS, + MAX_EVENTS, + MAX_QUEUE, + MAX_RESEARCH_OFFERS, + MAX_THREADS, + spikeAgreementLabel, + spikePreferenceLabel, +} from "./validation"; + +function base(): ConversationPlan.EventBase { + return { + id: "event-1", + threadId: "thread-1", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + }; +} +function human(): ConversationPlan.EventBase { + return { ...base(), origin: "human", actor: { kind: "member", handle: "maggie" } }; +} +function source( + role: ConversationPlan.SourceRole = "question", + quote = "Which?", +): ConversationPlan.SourceRef { + return { + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote, + start: 0, + end: quote.length, + role, + }; +} + +test("explicit human decisions validate without claiming a chat quote", () => { + expect(() => + assertEventShape({ ...human(), type: "decision.recorded", text: "Use Bun", explicit: true }) + ) + .not.toThrow(); + expect(() => + assertEventShape({ ...human(), type: "decision.recorded", text: "Use Bun", explicit: false }) + ) + .toThrow("decision requires explicit resolution"); + expect(() => + assertEventShape({ + ...human(), + type: "decision.recorded", + text: "Use Bun", + explicit: true, + source: source(), + }) + ) + .toThrow("human action cannot claim a message quote"); +}); + +let optionId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +function planner(): ConversationPlan.EventBase { + return { ...base(), origin: "planner", actor: { kind: "agent" } }; +} + +test("all archived event kinds retain their field-validation path", () => { + let events = [ + { ...base(), type: "thread.opened", source: source(), question: "Which?" }, + { ...planner(), type: "thread.opened", question: "Which?" }, + ...["option.added", "reason.added", "constraint.added"].map(type => ({ + ...base(), + type, + source: source("option"), + contribution: { id: "contribution-1", text: "Bun", authoring: "quoted" }, + })), + { + ...human(), + type: "option.added", + contribution: { id: optionId, text: "Bun", authoring: "human-edited" }, + }, + { + ...human(), + type: "reason.added", + source: source("reason"), + contribution: { id: optionId, text: "Which?", authoring: "quoted" }, + }, + { + ...base(), + type: "stance.changed", + source: source("support"), + position: "support", + scopedProposalId: null, + }, + { ...planner(), type: "option.relabeled", optionId, label: "Bun", observedCardRevision: 0 }, + { ...base(), type: "thread.leaning", source: source("support") }, + { ...base(), type: "card.linked", questionnaireId: "card-1" }, + ...["settle.suggested", "settle.agreed"].map(type => ({ + ...base(), + type, + source: source("support"), + optionId, + })), + { ...base(), type: "settle.deferred", source: source("constraint"), proposalId: "proposal-1" }, + { + ...base(), + type: "settle.resumed", + source: source("verification"), + proposalId: "proposal-1", + deferredEventId: "deferred-1", + }, + { + ...base(), + type: "scoped-choice.proposed", + source: source("support", "I'd pick Bun for the spike."), + cardId: "card-1", + optionId, + label: "Bun", + scope: "spike", + }, + { + ...base(), + type: "scoped-choice.agreed", + source: source("support", "yep, Bun for the spike."), + proposalId: "proposal-1", + cardId: "card-1", + optionId, + label: "Bun", + scope: "spike", + }, + { + ...human(), + type: "scoped-choice.saved", + proposalId: "proposal-1", + cardId: "card-1", + optionId, + label: "Bun", + scope: "spike", + sources: [source("support")], + expectedGeneration: 0, + }, + { ...human(), type: "thread.discarded" }, + { + ...base(), + type: "decision.recorded", + source: source("resolution"), + text: "Use Bun", + explicit: true, + }, + { ...human(), type: "decision.reopened", explicit: true }, + { ...base(), type: "decision.reopened", source: source("reopening"), explicit: true }, + { + ...base(), + type: "candidate.proposed", + source: source("resolution"), + candidate: { id: "candidate-1", kind: "resolution", text: "Use Bun" }, + }, + ...["candidate.confirmed", "candidate.rejected"].map(type => ({ + ...human(), + type, + candidateId: "candidate-1", + })), + { + ...human(), + type: "card.corrected", + change: { kind: "edit", field: "question", text: "Which runtime?" }, + }, + ]; + for (let event of events) expect(() => assertEventShape(event)).not.toThrow(); +}); + +test("event IDs, versions, unknown fields, and actor origin must validate", () => { + let event = { ...base(), type: "thread.opened", source: source(), question: "Which?" }; + for ( + let override of [ + { id: "" }, + { id: "x".repeat(201) }, + { threadId: "" }, + { observedThreadVersion: -1 }, + { observedThreadVersion: 0.5 }, + { at: Number.MAX_SAFE_INTEGER + 1 }, + { actor: { kind: "member", handle: "maggie" } }, + { origin: "other" }, + { actor: { kind: "classifier", extra: true } }, + { extra: true }, + { source: { ...source(), quote: "Wrong" } }, + { question: "x".repeat(501) }, + { type: "unknown" }, + ] + ) expect(() => assertEventShape({ ...event, ...override })).toThrow(); + expect(() => assertEventShape({ ...event, id: "x".repeat(200), question: "x".repeat(500) })).not + .toThrow(); + expect(() => + assertEventShape({ ...planner(), type: "thread.opened", question: "x".repeat(1000) }) + ).not.toThrow(); + expect(() => + assertEventShape({ ...planner(), type: "thread.opened", question: "x".repeat(1001) }) + ).toThrow(); +}); + +test("human event restrictions and explicit decisions remain distinct from correction actions", () => { + for ( + let event of [ + { ...base(), type: "decision.recorded", text: "Use Bun", explicit: true }, + { ...human(), type: "decision.reopened", explicit: false }, + { ...base(), type: "thread.discarded" }, + { ...base(), type: "candidate.confirmed", candidateId: "candidate-1" }, + { + ...base(), + type: "card.corrected", + change: { kind: "edit", field: "question", text: "Which?" }, + }, + { ...human(), type: "card.corrected", change: { kind: "record-decision", text: "Use Bun" } }, + { + ...human(), + type: "option.added", + contribution: { id: "not-a-ulid", text: "Bun", authoring: "human-edited" }, + }, + ] + ) expect(() => assertEventShape(event)).toThrow(); + expect(() => + assertCorrectionAction({ + actionId: "action-1", + threadId: "thread-1", + expectedVersion: 0, + change: { kind: "record-decision", text: "Use Bun" }, + }) + ).not.toThrow(); +}); + +test("scoped saves validate bounded unique support IDs and matching member sources", () => { + let event = { + ...human(), + type: "scoped-choice.saved", + proposalId: "proposal-1", + cardId: "card-1", + optionId, + label: "Bun", + scope: "spike", + expectedGeneration: 0, + }; + expect(() => + assertEventShape({ ...event, supportEventIds: ["support-1"], sources: [source("support")] }) + ).not.toThrow(); + for ( + let fields of [ + { supportEventIds: [], sources: [] }, + { + supportEventIds: ["support-1", "support-1"], + sources: [source("support"), source("support")], + }, + { + supportEventIds: Array.from({ length: MAX_EVENTS + 1 }, (_, index) => `support-${index}`), + sources: [], + }, + { supportEventIds: ["support-1"], sources: [] }, + { supportEventIds: ["support-1"], agreementId: "agreement-1", sources: [source("support")] }, + { + supportEventIds: ["support-1", "support-2"], + sources: [source("support"), source("support")], + }, + { sources: [source("question")] }, + ] + ) expect(() => assertEventShape({ ...event, ...fields })).toThrow(); +}); + +test("correction action variants validate scoped IDs and fields without applying changes", () => { + let changes = [ + { kind: "add-excerpt", messageId: "message-1", start: 0, end: 500, contributionKind: "option" }, + { kind: "edit", field: "question", text: "Which?" }, + { kind: "edit", field: "decision", text: "Use Bun" }, + { kind: "edit", field: "contribution", contributionId: "contribution-1", text: "Bun" }, + { + kind: "move", + contributionId: "contribution-1", + targetThreadId: "thread-2", + targetVersion: 0, + }, + { kind: "set-status", status: "reopened" }, + { kind: "retarget-stance", stanceId: "stance-1", optionId }, + { kind: "dismiss-stance", stanceId: "stance-1" }, + { kind: "retarget-contribution", contributionId: "contribution-1", targetId: optionId }, + { kind: "record-decision", text: "Use Bun", optionId }, + { kind: "confirm-candidate", candidateId: "candidate-1" }, + { kind: "reject-candidate", candidateId: "candidate-1" }, + ]; + for (let change of changes) { + expect(() => + assertCorrectionAction({ + actionId: "action-1", + threadId: "thread-1", + expectedVersion: 0, + change, + }) + ).not.toThrow(); + } + let action = { + actionId: "action-1", + threadId: "thread-1", + expectedVersion: 0, + change: { kind: "edit", field: "question", text: "Which?" }, + }; + for ( + let fields of [ + { actionId: "" }, + { threadId: "x".repeat(201) }, + { expectedVersion: -1 }, + { extra: true }, + { change: { ...action.change, extra: true } }, + { change: { ...action.change, contributionId: "unexpected" } }, + { change: { kind: "edit", field: "contribution", text: "Bun" } }, + { change: { kind: "set-status", status: "decided" } }, + { + change: { + kind: "add-excerpt", + messageId: "message-1", + start: 0, + end: 501, + contributionKind: "option", + }, + }, + { + change: { + kind: "move", + contributionId: "contribution-1", + targetThreadId: "thread-2", + targetVersion: 0.5, + }, + }, + ] + ) expect(() => assertCorrectionAction({ ...action, ...fields })).toThrow(); +}); + +test("spike wording and exported snapshot bounds retain the archived contract", () => { + expect(spikePreferenceLabel("I'd pick Bun for the spike.")).toBe("Bun"); + expect(spikeAgreementLabel("yep, Bun for a spike!")).toBe("Bun"); + expect(spikePreferenceLabel("We should use Bun.")).toBeUndefined(); + expect(spikeAgreementLabel("yep, Bun for production.")).toBeUndefined(); + expect([MAX_THREADS, MAX_EVENTS, MAX_ANALYSIS, MAX_QUEUE, MAX_RESEARCH_OFFERS]) + .toEqual([20, 4096, 64, 128, 64]); +}); diff --git a/apps/server/src/conversation-plan/validation.ts b/apps/server/src/conversation-plan/validation.ts new file mode 100644 index 00000000..4499b68c --- /dev/null +++ b/apps/server/src/conversation-plan/validation.ts @@ -0,0 +1,17 @@ +export { assertCorrectionAction } from "./correction-validation"; +export { assertEventShape } from "./event-validation"; +export { + assertResearchOfferShape, + renderResearchTask, + researchNamedOptionIds, +} from "./research-validation"; +export { assertStateShape } from "./state-validation"; +export { + MAX_ANALYSIS, + MAX_EVENTS, + MAX_QUEUE, + MAX_RESEARCH_OFFERS, + MAX_THREADS, + spikeAgreementLabel, + spikePreferenceLabel, +} from "./validation-fields"; diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index a14b42d3..f190b355 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -4,9 +4,12 @@ import { z } from "zod"; import { type DocumentRoom, documentTools } from "../agent/tools"; import { type HostedRepository, repositoryTools } from "../agent/repository"; +import { jobTools, scopedJobTools } from "../agent/job-tools"; +import { createScopedTools } from "../agent/scoped-tools"; +import type { ConversationPlan } from "@chopin/protocol"; import { harnessSelection } from "../config"; import { GITHUB_TOOL_SCHEMAS } from "./github-tools"; -import { PLANNER_TOOL_NAMES } from "./tool-names"; +import { BACKGROUND_TOOL_NAMES, PLANNER_TOOL_NAMES } from "./tool-names"; import { harnessFor } from "./harnesses"; import type { HarnessV1 } from "@ai-sdk/harness"; @@ -27,7 +30,13 @@ const githubPlaceholders: ToolSet = Object.fromEntries( ]), ); -const plannerTools: ToolSet = { ...documentTools, ...repositories, ...githubPlaceholders }; +const plannerTools: ToolSet = { + ...documentTools, + ...repositories, + ...githubPlaceholders, + ...jobTools, + ...createScopedTools(), +}; export { PLANNER_TOOL_NAMES } from "./tool-names"; type PlannerCallOptions = { @@ -39,28 +48,34 @@ type PlannerCallOptions = { model?: string; }; -export function createPlannerAgent(harness: HarnessV1) { +export function createPlannerAgent(harness: HarnessV1, profile?: ConversationPlan.JobKind) { + let names = profile ? BACKGROUND_TOOL_NAMES[profile] : PLANNER_TOOL_NAMES; return new HarnessAgent({ harness, tools: plannerTools, - activeTools: PLANNER_TOOL_NAMES, + activeTools: [...names], permissionMode: "allow-reads", callOptionsSchema: z.custom(), - prepareCall: ({ options, ...rest }) => ({ - ...rest, - model: options.model, - instructions: options.instructions, - tools: { ...rest.tools, ...options.githubTools }, - toolsContext: Object.fromEntries(PLANNER_TOOL_NAMES.map(name => [name, { - room: options.room, - repository: options.repository, - owner: options.owner, - }])), - }), + prepareCall: ({ options, ...rest }) => { + return { + ...rest, + model: options.model, + instructions: options.instructions, + tools: scopedJobTools({ ...rest.tools, ...options.githubTools }, options.room), + toolsContext: Object.fromEntries(names.map(name => [name, { + room: options.room, + repository: options.repository, + owner: options.owner, + }])), + }; + }, }); } export let plannerAgent = createPlannerAgent(harnessFor(harnessSelection())); +export let headingPlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "heading"); +export let refinePlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "refine"); +export let prosePlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "prose"); type WorkerCallOptions = { model: string; diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index a22664a3..e5e1346b 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -1,5 +1,6 @@ +import { BACKGROUND_TOOL_NAMES, PLANNER_TOOL_NAMES } from "./tool-names"; import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; -import { plannerAgent } from "./agents"; +import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; @@ -26,12 +27,17 @@ type PlannerChannel = { }; export type PlannerSession = { + /** Snapshot of the selected current static Planner profile. */ + activeTools?: readonly string[]; stream: (prompt: string, abortSignal: AbortSignal) => ReturnType; destroy: () => Promise; }; export type PlannerSessionDependencies = { agent?: PlannerAgent; + headingAgent?: PlannerAgent; + refineAgent?: PlannerAgent; + proseAgent?: PlannerAgent; githubTools?: typeof githubTools; createSandbox?: () => Promise; registerCredential?: typeof registerCredential; @@ -43,6 +49,16 @@ export async function openPlannerSession( channel: PlannerChannel, deps: PlannerSessionDependencies = {}, ): Promise> { + let job = channel.room.plan.chat?.job; + if (job && !Object.hasOwn(BACKGROUND_TOOL_NAMES, job.kind)) { + return { + ok: false, + error: { + kind: "Unavailable", + cause: new Error(`Background ${job.kind} tools are not available`), + }, + }; + } try { if (owner.signal.aborted || !await owner.revalidate()) { return { @@ -73,7 +89,13 @@ export async function openPlannerSession( if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); - let agent = deps.agent ?? plannerAgent; + let agent = deps.agent ?? (job?.kind === "heading" + ? deps.headingAgent ?? headingPlannerAgent + : job?.kind === "refine" || job?.kind === "suggest" + ? deps.refineAgent ?? refinePlannerAgent + : job?.kind === "prose" + ? deps.proseAgent ?? prosePlannerAgent + : plannerAgent); let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); let deadline = new Promise((_, reject) => { timeout = setTimeout( @@ -94,6 +116,9 @@ export async function openPlannerSession( return { ok: true, value: { + activeTools: Object.freeze([ + ...(job ? BACKGROUND_TOOL_NAMES[job.kind] : PLANNER_TOOL_NAMES), + ]), stream: (prompt, abortSignal) => agent.stream({ session: active, diff --git a/apps/server/src/harness/tool-names.ts b/apps/server/src/harness/tool-names.ts index a099be7f..8ac9062f 100644 --- a/apps/server/src/harness/tool-names.ts +++ b/apps/server/src/harness/tool-names.ts @@ -1,3 +1,6 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { WRITE_TOOLS } from "../agent/job-scope"; + export const PLANNER_TOOL_NAMES = [ "read_plan", "read_reference", @@ -15,4 +18,19 @@ export const PLANNER_TOOL_NAMES = [ "repository_history", "list_pull_requests", "pull_request_read", + "revise_open_decision", ]; + +function jobNames(own: string): readonly string[] { + return Object.freeze([...PLANNER_TOOL_NAMES.filter(name => !WRITE_TOOLS.has(name)), own]); +} + +export const BACKGROUND_TOOL_NAMES: Readonly> = + Object.freeze({ + heading: jobNames("draft_heading"), + refine: jobNames("refine_decision"), + suggest: jobNames("refine_decision"), + prose: jobNames("write_decision_prose"), + }); + +export const HEADING_TOOL_NAMES = BACKGROUND_TOOL_NAMES.heading; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7212ab18..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,10 +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 { ResearchWorkspaceService } from "./research/service"; +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"; @@ -70,6 +83,7 @@ const LEASE_RENEW_MS = 10_000; const LEASE_SAFETY_MS = 5_000; const SESSION_CLEANUP_MS = 5 * 60_000; const ACCESS_RECHECK_MS = 60_000; +const RESEARCH_RECOVERY_RETRY_MS = 10_000; let server: Server; let heldLease: Lease | undefined; @@ -81,6 +95,8 @@ let cleaningSessions: Promise | undefined; let ownerBindings: ActiveOwnerBindings | undefined; let jobRunner: JobRunner | undefined; let researchService: ResearchWorkspaceService | undefined; +let researchRecoveryTimer: ReturnType | undefined; +let recoveringResearch: Promise | undefined; let referenceService: ReferenceService | undefined; let summaryCoordinator: DocumentSummaryCoordinator | undefined; let descriptionProjector: DocumentDescriptionProjector | undefined; @@ -88,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(); @@ -119,17 +140,8 @@ function presence(server: Server, room: Rooms.Room): void { }); } -/** - * Attach the document to a room, once. - * - * Two clients opening at the same moment must not build two documents, so the - * first stores its promise and the second waits on it. - */ -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; - let backend: Service.Backend = { +function documentBackend(): Service.Backend { + return { storage, lease: () => { if (!heldLease) throw new Error("storage writer lease is unavailable"); @@ -141,12 +153,77 @@ async function plan(room: Rooms.Room, server: Server): Promise summaryCoordinator?.schedule(target), }; +} + +function placeResearchReference( + channelId: string, + workspaceId: string, +): Promise<"placed" | "deferred"> { + return placeResearch(channelId, workspaceId, { + get: Rooms.get, + exclusive: withDocumentLock, + detached: async (id, action) => { + let detached = await Service.open(id, documentBackend(), server); + try { + return await action(detached); + } finally { + await Service.close(detached); + } + }, + }); +} + +function scheduleResearchRecovery(deferred: number): void { + if (deferred === 0 || researchRecoveryTimer || draining) return; + researchRecoveryTimer = setTimeout(() => { + researchRecoveryTimer = undefined; + if (draining) return; + recoveringResearch = researchService!.recoverPendingPlannerInline(placeResearchReference).then( + result => { + scheduleResearchRecovery(result.deferred); + }, + err => { + console.error("chopin: research reference recovery failed -", err); + signal(); + }, + ).finally(() => { + recoveringResearch = undefined; + }); + }, RESEARCH_RECOVERY_RETRY_MS); +} + +/** + * Attach the document to a room, once. + * + * Two clients opening at the same moment must not build two documents, so the + * first stores its promise and the second waits on it. + */ +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.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); @@ -155,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, @@ -186,14 +266,23 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { ? async request => { let service = researchService; if (!service) throw new Error("research workspaces are unavailable"); - let created = await service.startPlanner({ - channelId: room.id, - question: request.question, - originMessageId: request.entryId, - requestedBy: request.userId, - requestedByHandle: request.handle, - beforeStart: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), - }); + let created; + try { + created = await service.startPlannerInline({ + channelId: room.id, + question: request.question, + originMessageId: request.entryId, + requestedBy: request.userId, + requestedByHandle: request.handle, + beforeStart: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), + placeReference: id => placeResearchReference(room.id, id), + }); + } catch (err) { + if (err instanceof ResearchWorkspaceError && err.code === "not-ready") { + scheduleResearchRecovery(1); + } + throw err; + } return { workspaceId: created.request.id, state: created.request.state, @@ -201,7 +290,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { }; } : undefined, - }; + }); } async function closeRoom(room: Rooms.Room, force = false): Promise { @@ -210,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; @@ -266,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"); } @@ -291,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": @@ -302,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; @@ -310,12 +474,31 @@ async function receive(ws: Socket, raw: string): Promise { if (room.plan) await Questions.edit(room.plan, ws, frame); return; + case "question:discard": + if (room.plan) await Questions.discard(room.plan, server, room.id, ws, frame); + return; + + case "question:reopen": + if (room.plan) await Questions.reopen(room.plan, server, room.id, ws, frame); + return; + case "question:presence": if (room.plan) Questions.focus(room.plan, ws, frame); 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": @@ -383,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( @@ -636,12 +808,20 @@ function drain(): Promise { } }; await attempt(() => server.stop(true)); + if (researchRecoveryTimer) clearTimeout(researchRecoveryTimer); + if (recoveringResearch) await attempt(() => recoveringResearch!); if (sessionCleanup) clearInterval(sessionCleanup); for (let result of await Promise.allSettled([cleaningSessions])) { 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); @@ -771,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); } @@ -785,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); + } + } } } @@ -800,7 +994,14 @@ async function restoreChannelLocked(channelId: string, now: Date) { channelId, () => storage.channels.restore({ id: channelId, now }), ); + let recovery = await researchService?.recoverPendingPlannerInline( + placeResearchReference, + channelId, + ); + 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; @@ -828,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`); @@ -899,6 +1104,32 @@ function announceResearchChanged(channelId: string, workspaceId: string, revisio }); } +function announceResearchTerminal(channelId: string, id: string, text: string): Promise { + return withDocumentLock(channelId, async () => { + let active = Rooms.get(channelId)?.plan; + if (active) { + let existing = active.chat.entries.find(entry => entry.id === id); + await Chat.noticeOnce( + { chat: active.chat, plan: active, server, room: channelId }, + id, + existing?.text ?? text, + ); + return; + } + let detached = await Service.open(channelId, documentBackend(), server); + try { + let existing = detached.chat.entries.find(entry => entry.id === id); + await Chat.noticeOnce( + { chat: detached.chat, plan: detached, server, room: channelId }, + id, + existing?.text ?? text, + ); + } finally { + await Service.close(detached); + } + }); +} + async function currentDocumentTarget( channelId: string, ): Promise { @@ -1039,6 +1270,7 @@ researchService = new ResearchWorkspaceService({ }, current: currentDocumentTarget, publish: announceResearchChanged, + terminalNotice: announceResearchTerminal, }); referenceService = new ReferenceService({ storage, @@ -1177,10 +1409,16 @@ leaseRenewal = setInterval(renewLease, LEASE_RENEW_MS); cleanSessions(); sessionCleanup = setInterval(cleanSessions, SESSION_CLEANUP_MS); +let listening: Server | undefined; try { - server = listen(); + server = listening = listen(); + let recovery = await researchService.recoverPendingPlannerInline(placeResearchReference); + scheduleResearchRecovery(recovery.deferred); + await researchService.recoverTerminalPlannerInline(); jobRunner.start(); } catch (err) { + if (listening) await listening.stop(true).catch(() => {}); + if (researchRecoveryTimer) clearTimeout(researchRecoveryTimer); if (sessionCleanup) clearInterval(sessionCleanup); if (leaseRenewal) clearInterval(leaseRenewal); if (leaseWatchdog) clearTimeout(leaseWatchdog); diff --git a/apps/server/src/mcp/update-lifecycle.test.ts b/apps/server/src/mcp/update-lifecycle.test.ts index 77f015a4..86c738e6 100644 --- a/apps/server/src/mcp/update-lifecycle.test.ts +++ b/apps/server/src/mcp/update-lifecycle.test.ts @@ -445,7 +445,7 @@ describe("hosted document rewrite lifecycle", () => { header: "Keep", question: "What should stay?", multiple: false, - options: [], + options: [{ id: "option", label: "Keep", description: "" }], }], }, }], diff --git a/apps/server/src/plan/card-answer-projection.test.ts b/apps/server/src/plan/card-answer-projection.test.ts new file mode 100644 index 00000000..73711f9d --- /dev/null +++ b/apps/server/src/plan/card-answer-projection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("questionnaire answer projection", () => { + it("writes chosen option identifiers beside the answer summary", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + room.insertQuestionnaire(document, card); + let question = card.questions[0]!; + + let mutation = room.projectAnswer( + document, + card.id, + { [question.id]: "Choose this" }, + undefined, + { [question.id]: [question.options[0]!.id] }, + ); + + expect(mutation?.source).toContain( + ` { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + let previous = { choices: [question.options[0]!.id], by: "ana", at: "2026-09-23" }; + room.insertQuestionnaire(document, { + ...card, + status: "reopened", + questions: [{ ...question, previous }], + }); + let mutation = room.projectAnswer( + document, + card.id, + { [question.id]: "Choose this" }, + { by: "ben", at: "2026-09-25" }, + { [question.id]: [question.options[0]!.id] }, + ); + expect(mutation?.source).toContain('status="decided"'); + expect(mutation?.source).toContain(" { + let document = await room.create(); + expect(() => room.projectAnswer(document, ulid(), {})).toThrow(/Card is not available/); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaires(document, [ + { value: card }, + { value: questionnaire(card.id, ulid(), ulid(), "Second") }, + ]); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, {})).toThrow(/Card is not available/); + expect(room.project(document)).toBe(before); + }); + + it("refuses an answer whose question differs from the card", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, { [ulid()]: "Wrong card" })) + .toThrow(/Card questions do not match/); + expect(room.project(document)).toBe(before); + }); + + it("refuses duplicate question IDs inside one answer target", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + let question = card.questions[0]!; + room.insertQuestionnaire(document, { + ...card, + questions: [question, { + ...question, + header: "Second", + options: [{ ...question.options[0]!, id: ulid() }], + }], + }); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, { [question.id]: "Once" })) + .toThrow(/Card questions do not match/); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/card-lifecycle-projection.test.ts b/apps/server/src/plan/card-lifecycle-projection.test.ts new file mode 100644 index 00000000..f3aa6bfe --- /dev/null +++ b/apps/server/src/plan/card-lifecycle-projection.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("card lifecycle projection", () => { + it("marks a card discarded without removing its answer or provenance", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + room.insertQuestionnaire(document, card); + room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, { + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }, { [question.id]: [question.options[0]!.id] }); + + let mutation = room.projectCard(document, card.id, { status: "discarded" }); + + expect(mutation?.source).toContain('status="discarded"'); + expect(mutation?.source).toContain('by="ana"'); + expect(mutation?.source).toContain(' { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + room.insertQuestionnaires(document, [ + { value: card }, + { value: questionnaire(card.id, ulid(), ulid(), "Other") }, + ]); + let before = room.project(document); + + expect(() => room.projectCard(document, ulid(), { status: "discarded" })) + .toThrow(/Card is not available/); + expect(() => room.projectCard(document, card.id, { status: "discarded" })) + .toThrow(/Card is not available/); + expect(room.project(document)).toBe(before); + document.doc.destroy(); + }); + + it("can clear an answer and show its previous choice when reopened", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + let option = question.options[0]!.id; + room.insertQuestionnaire(document, card); + room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, { + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }, { [question.id]: [option] }); + + let mutation = room.projectCard(document, card.id, { + status: "reopened", + clearAnswers: true, + previous: { + [question.id]: { choices: [option], by: "ana", at: "2026-09-23T17:30:00.000Z" }, + }, + }); + + expect(mutation?.source).toContain('status="reopened"'); + expect(mutation?.source).toContain(` { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + let question = { + id: card.questions[0]!.id, + header: "First", + question: "What should First be?", + multiple: false, + options: [{ id: ulid(), label: "GitHub Apps", description: "" }], + }; + + expect(() => room.projectOptions(document, ulid(), question)).toThrow(); + expect(() => room.projectOptions(document, card.id, { ...question, id: ulid() })).toThrow(); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/card-projections.test-fixtures.ts b/apps/server/src/plan/card-projections.test-fixtures.ts new file mode 100644 index 00000000..c206a2a3 --- /dev/null +++ b/apps/server/src/plan/card-projections.test-fixtures.ts @@ -0,0 +1,12 @@ +export function questionnaire(id: string, question: string, option: string, header: string) { + return { + id, + questions: [{ + id: question, + header, + prompt: `What should ${header} be?`, + multiple: false, + options: [{ id: option, label: "Choose this" }], + }], + }; +} diff --git a/apps/server/src/plan/card-projections.ts b/apps/server/src/plan/card-projections.ts new file mode 100644 index 00000000..d987d356 --- /dev/null +++ b/apps/server/src/plan/card-projections.ts @@ -0,0 +1,152 @@ +import { $nodesOfType } from "lexical"; +import { QuestionnaireNode } from "@chopin/dialect"; +import type { Previous } from "@chopin/dialect"; +import type * as Question from "@chopin/question"; +import type { Document, Mutation } from "./room"; + +export type CardChange = { + status: "open" | "decided" | "reopened" | "discarded"; + clearAnswers?: boolean; + previous?: { [question: string]: Previous | null }; +}; + +export class QuestionnaireProjectionError extends Error {} + +export function createCardProjections( + mutate: (target: Document, change: () => boolean) => Mutation | undefined, +) { + function projectAnswer( + target: Document, + id: string, + answers: Record, + settled?: { by: string; at: string }, + chosen?: Record, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + let known = new Set(value.questions.map(question => question.id)); + if ( + known.size !== value.questions.length + || known.size !== Object.keys(answers).length + || Object.keys(answers).some(question => !known.has(question)) + || Object.keys(chosen ?? {}).some(question => !known.has(question)) + ) throw new QuestionnaireProjectionError("Card questions do not match its record"); + node.setQuestionnaire({ + ...value, + // Resolution belongs to the questionnaire, not each answer. + ...(settled ? { by: settled.by, at: settled.at, status: "decided" as const } : {}), + questions: value.questions.map(question => { + let answer = answers[question.id]; + if (answer === undefined) return question; + let projected = { ...question, answer }; + let ids = chosen?.[question.id]; + if (ids?.length) projected.choices = ids; + else delete projected.choices; + return projected; + }), + }); + return true; + }); + } + + function projectCard( + target: Document, + id: string, + change: CardChange, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + let known = new Set(value.questions.map(question => question.id)); + if ( + known.size !== value.questions.length + || Object.keys(change.previous ?? {}).some(question => !known.has(question)) + ) throw new QuestionnaireProjectionError("Card questions do not match its record"); + let { by: _by, at: _at, ...unsettled } = value; + node.setQuestionnaire({ + ...(change.clearAnswers ? unsettled : value), + status: change.status, + questions: value.questions.map(question => { + let next = { ...question }; + if (change.clearAnswers) { + delete next.answer; + delete next.choices; + } + let previous = change.previous?.[question.id]; + if (previous === null) delete next.previous; + else if (previous) next.previous = previous; + return next; + }), + }); + return true; + }); + } + + function hasQuestionnaire(target: Document, id: string, questionId: string): boolean { + return target.editor.getEditorState().read(() => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) return false; + let questions = matches[0]!.getQuestionnaire().questions; + return questions.length === 1 && questions[0]?.id === questionId; + }); + } + + function projectOptions( + target: Document, + id: string, + question: Question.Item, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + if (value.questions.filter(item => item.id === question.id).length !== 1) { + throw new QuestionnaireProjectionError("Card question does not match its record"); + } + node.setQuestionnaire({ + ...value, + questions: value.questions.map(item => + item.id !== question.id ? item : { + ...item, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + } + ), + }); + return true; + }); + } + + function projectPrompt( + target: Document, + id: string, + questionId: string, + prompt: string, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + if (value.questions.length !== 1 || value.questions[0]?.id !== questionId) { + throw new QuestionnaireProjectionError("Card question does not match its record"); + } + node.setQuestionnaire({ + ...value, + questions: [{ ...value.questions[0]!, prompt }], + }); + return true; + }); + } + + return { projectAnswer, projectCard, hasQuestionnaire, projectOptions, projectPrompt }; +} diff --git a/apps/server/src/plan/card-prompt-projection.test.ts b/apps/server/src/plan/card-prompt-projection.test.ts new file mode 100644 index 00000000..a03b1c2b --- /dev/null +++ b/apps/server/src/plan/card-prompt-projection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("questionnaire prompt projection", () => { + it("rewrites only the matching card question", async () => { + let document = await room.create(); + let first = questionnaire(ulid(), ulid(), ulid(), "First"); + let second = questionnaire(ulid(), ulid(), ulid(), "Second"); + room.insertQuestionnaires(document, [{ value: first }, { value: second }]); + expect(room.hasQuestionnaire(document, first.id, first.questions[0]!.id)).toBe(true); + expect(room.hasQuestionnaire(document, first.id, second.questions[0]!.id)).toBe(false); + let mutation = room.projectPrompt( + document, + first.id, + first.questions[0]!.id, + "Which feature should ship first?", + ); + expect(mutation?.source).toContain('prompt="Which feature should ship first?"'); + expect(mutation?.source).toContain('prompt="What should Second be?"'); + expect(mutation?.source.match(/prompt="Which feature should ship first\?"/g)).toHaveLength(1); + }); + + it("fails closed on a missing card or mismatched question", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + expect(room.hasQuestionnaire(document, ulid(), card.questions[0]!.id)).toBe(false); + expect(() => room.projectPrompt(document, ulid(), card.questions[0]!.id, "Why?")).toThrow( + /available/i, + ); + expect(() => room.projectPrompt(document, card.id, ulid(), "Why?")).toThrow(/match/i); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/conversation-analysis-persistence.test.ts b/apps/server/src/plan/conversation-analysis-persistence.test.ts new file mode 100644 index 00000000..57c1418b --- /dev/null +++ b/apps/server/src/plan/conversation-analysis-persistence.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { enqueue } from "../conversation-plan/domain"; + +async function damage( + context: Awaited>, + mutate: (sidecar: Record) => void, +) { + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + mutate(sidecar); + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: crypto.randomUUID(), + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); +} + +test("conversation analysis and explicit retry identity survive a real sidecar commit", async () => { + let context = await openPlan(); + let state = enqueue(context.plan.conversationPlan, "message-1"); + context.plan.chat.entries.push({ + id: "message-1", + author: { kind: "member", handle: "ana" }, + text: "An outline?", + ts: 1, + }); + context.plan.conversationPlan = state; + context.plan.conversationPlanRetries = [{ id: "retry-1", messageId: "message-1" }]; + await Service.persist(context.plan); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.conversationPlan).toEqual(state); + expect(restored.conversationPlanRetries).toEqual([{ id: "retry-1", messageId: "message-1" }]); + expect(restored.document.seq).toBe(0); + expect(restored.revision).toBe(0); + } finally { + await Service.close(restored); + } +}); + +test("retry restoration rejects malformed, duplicate and absent source identities", async () => { + let cases = [ + { value: [{ id: "retry", messageId: "missing" }], error: "without a source message" }, + { + value: [{ id: "retry", messageId: "m1", extra: true }], + error: "invalid conversation analysis retry", + }, + { + value: [{ id: "retry", messageId: "m1" }, { id: "retry", messageId: "m1" }], + error: "invalid conversation analysis retry", + }, + { + value: [{ id: "x".repeat(251), messageId: "m1" }], + error: "invalid conversation analysis retry", + }, + ]; + for (let item of cases) { + let context = await openPlan(); + await Service.close(context.plan); + await damage(context, sidecar => { + sidecar.conversationPlanRetries = item.value; + sidecar.transcript = [{ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "Hi", + ts: 1, + }]; + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow(item.error); + } +}); + +test("failed conversation persistence leaves durable state unchanged and can be retried", async () => { + let context = await openPlan(); + let original = context.storage.collaboration.commit; + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.plan.persistence.fatal = context.backend.fatal; + let fail = true; + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + context.plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "An outline?", + ts: 1, + }); + context.plan.conversationPlan = enqueue(context.plan.conversationPlan, "m1"); + await expect(Service.persist(context.plan)).rejects.toThrow("storage unavailable"); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect((sidecar as { transcript: unknown[] }).transcript).toEqual([]); + expect(fatals).toHaveLength(1); + await Service.persist(context.plan); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.conversationPlan.queue).toEqual([{ + messageId: "m1", + status: "pending", + attempts: 0, + }]); + } finally { + await Service.close(restored); + } +}); diff --git a/apps/server/src/plan/conversation-option-persistence.test.ts b/apps/server/src/plan/conversation-option-persistence.test.ts new file mode 100644 index 00000000..b0dd1bd2 --- /dev/null +++ b/apps/server/src/plan/conversation-option-persistence.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { draft, legacyRecord } from "./question-record.test-fixtures"; +import { applyInference, initialState } from "../conversation-plan/domain"; +import { applyEvent } from "../conversation-plan/events"; + +async function accepted() { + let context = await openPlan("", { questions: [legacyRecord()], openQuestions: [draft()] }); + let message = { + id: "m1", + author: { kind: "member" as const, handle: "ana" }, + text: "Try 🧪 Cloud next", + ts: 1, + }; + let source = { + messageId: message.id, + author: message.author, + quote: "🧪 Cloud", + start: 4, + end: 12, + role: "option" as const, + }; + let state = applyEvent(initialState(), { + id: "open", + type: "thread.opened", + threadId: "t1", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Where should we host?", + }); + state = applyInference(state, { + id: "option", + type: "option.added", + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source, + contribution: { id: "saved-a", text: "Cloud", authoring: "scribe" }, + }, message); + let record = context.plan.records.get("saved-card")!; + context.plan.records.set(record.id, { + ...record, + threadId: "t1", + optionOrigins: { "saved-a": { origin: "chat", source } }, + }); + context.plan.chat.entries.push(message); + context.plan.conversationPlan = state; + await Service.persist(context.plan); + await Service.close(context.plan); + return context; +} + +test("saved chat option provenance is tied to the accepted event in its own thread", async () => { + let context = await accepted(); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.records.get("saved-card")?.optionOrigins["saved-a"]?.source?.quote) + .toBe("🧪 Cloud"); + expect(restored.conversationPlan.events.at(-1)?.type).toBe("option.added"); + } finally { + await Service.close(restored); + } +}); + +test("saved chat option provenance rejects wrong thread, contribution and exact source", async () => { + for (let kind of ["thread", "contribution", "source"]) { + let context = await accepted(); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as { + questions: Array<{ + threadId: string; + optionOrigins: Record< + string, + { origin: string; source: { start: number; end: number; quote: string } } + >; + }>; + }; + let record = sidecar.questions[0]!; + if (kind === "thread") record.threadId = "other"; + if (kind === "contribution") { + record.optionOrigins["saved-b"] = record.optionOrigins["saved-a"]!; + delete record.optionOrigins["saved-a"]; + } + if (kind === "source") { + record.optionOrigins["saved-a"]!.source = { + ...record.optionOrigins["saved-a"]!.source, + start: 0, + end: 3, + quote: "Try", + }; + } + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: `wrong-${kind}`, + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("option source outside its card thread or evidence"); + } +}); diff --git a/apps/server/src/plan/conversation-outbox-persistence.test.ts b/apps/server/src/plan/conversation-outbox-persistence.test.ts new file mode 100644 index 00000000..a8c46ebf --- /dev/null +++ b/apps/server/src/plan/conversation-outbox-persistence.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +describe("durable conversation outboxes", () => { + it("restores the effect outbox and rejects malformed present entries", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let pending = { + key: "job:heading:document", + kind: "job" as const, + intent: { kind: "heading" as const, target: "document", trigger: "m1" }, + }; + await Service.exclusive(plan, async () => { + plan.conversationPlanEffects = ["insert:old-thread"]; + plan.conversationPlanPendingEffects = [pending]; + await Service.persistExclusive(plan); + }); + await Service.close(plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + expect(restored.conversationPlanEffects).toEqual(["insert:old-thread"]); + expect(restored.conversationPlanPendingEffects).toEqual([pending]); + await Service.close(restored); + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.conversationPlanPendingEffects = [{ ...pending, unexpected: true }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "invalid-effect-outbox", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("conversation effects"); + }); + + it("restores pending card actions and rejects malformed present entries", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let cardId = ulid(); + let action = { + id: `card:${cardId}:reopened:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 9, + kind: "reopened" as const, + generation: 1, + }; + await Service.exclusive(plan, async () => { + plan.pendingCardActions = [action]; + await Service.persistExclusive(plan); + }); + await Service.close(plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + expect(restored.pendingCardActions).toEqual([action]); + await Service.close(restored); + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.pendingCardActions = [{ ...action, generation: 0 }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "invalid-card-action-outbox", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("card actions"); + }); + + it("rejects pending analysis whose source message is absent", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let epoch = plan.document.epoch; + await Service.close(plan); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + let analysis = sidecar.conversationPlan as Record; + analysis.queue = [{ messageId: "missing", status: "pending", attempts: 0 }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "orphan-analysis", + epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("without a source message"); + }); +}); diff --git a/apps/server/src/plan/conversation-persistence.test-fixtures.ts b/apps/server/src/plan/conversation-persistence.test-fixtures.ts new file mode 100644 index 00000000..80589fd8 --- /dev/null +++ b/apps/server/src/plan/conversation-persistence.test-fixtures.ts @@ -0,0 +1,35 @@ +import { MemoryStorage } from "../storage/memory/adapter"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import * as Service from "./service"; + +export async function hosted() { + let now = new Date("2026-08-13T12:00:00.000Z"); + let storage = new MemoryStorage(); + await storage.users.put({ id: "U_octocat", login: "octocat", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Release", + createdBy: "U_octocat", + now, + }); + let lease = await storage.leases.acquire("writer", "test", 60_000); + if (!lease) throw new Error("could not acquire test lease"); + let frames: Array> = []; + let server = { + publish(_topic: string, value: string) { + frames.push(JSON.parse(value) as Record); + }, + } as unknown as Server; + let backend: Service.Backend = { + storage, + lease: () => lease, + fatal: err => { + throw err; + }, + }; + return { storage, channel, lease, frames, server, backend, now }; +} diff --git a/apps/server/src/plan/conversation-processor-persistence.test.ts b/apps/server/src/plan/conversation-processor-persistence.test.ts new file mode 100644 index 00000000..2056d8e1 --- /dev/null +++ b/apps/server/src/plan/conversation-processor-persistence.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +import { createProcessor } from "../conversation-plan/service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +describe("durable conversation processing", () => { + it("restores conversation analysis work with its saved source message", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let processor = createProcessor({ + plan, + exclusive: action => Service.exclusive(plan, action), + persist: () => Service.persistExclusive(plan), + publish: () => {}, + active: () => true, + }); + await processor.accept({ + id: "message-1", + author: { kind: "member", handle: "octocat" }, + text: "Should we use an outline?", + ts: 1, + }); + processor.stop(); + await Service.close(plan); + + let restored = await Service.open(context.channel.id, context.backend, context.server); + expect(restored.chat.entries.map(entry => entry.id)).toEqual(["message-1"]); + expect(restored.conversationPlan.queue).toEqual([{ + messageId: "message-1", + status: "pending", + attempts: 0, + }]); + await Service.close(restored); + }); +}); diff --git a/apps/server/src/plan/notice-validation.ts b/apps/server/src/plan/notice-validation.ts new file mode 100644 index 00000000..14ba0dbd --- /dev/null +++ b/apps/server/src/plan/notice-validation.ts @@ -0,0 +1,149 @@ +import { isDeepStrictEqual } from "node:util"; +import { ULID } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import { assertSourceShape, validateSource } from "../conversation-plan/sources"; +import type { ConversationPlan } from "@chopin/protocol"; +import type * as Chat from "../chat/service"; +import type { JsonValue } from "../storage/model"; +import type { Record as CardRecord } from "../questions/records"; + +export function validateNotice( + entry: Record, + savedMessages: Map>, +): void { + if (Object.hasOwn(entry, "decision")) { + let author = entry.author as Record; + let decision = entry.decision; + if ( + author.kind !== "system" || !decision || typeof decision !== "object" + || Array.isArray(decision) + ) throw new Error("hosted channel has an invalid transcript entry"); + let value = decision as Record; + if (value.kind === "scoped-choice") { + let fields = [ + "questionnaireId", + "kind", + "threadId", + "proposalId", + "cardId", + "optionId", + "label", + "scope", + "generation", + "triggerEventId", + "sources", + ]; + if ( + Object.keys(value).some(key => !fields.includes(key)) + || fields.some(key => !Object.hasOwn(value, key)) + || typeof value.cardId !== "string" || !ULID.test(value.cardId) + || value.questionnaireId !== value.cardId + || typeof value.optionId !== "string" || !ULID.test(value.optionId) + || [value.threadId, value.proposalId, value.triggerEventId].some(id => + typeof id !== "string" || !id || id.length > 200 + ) + || typeof value.label !== "string" || !value.label.trim() + || value.label.length > Question.limits.MAX_LABEL + || value.scope !== "spike" + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || !Array.isArray(value.sources) + || value.sources.length !== (value.triggerEventId === value.proposalId ? 1 : 2) + ) throw new Error("hosted channel has an invalid transcript entry"); + let sourceIds = new Set(); + for (let rawSource of value.sources) { + try { + assertSourceShape(rawSource); + if ( + rawSource.role !== "support" || rawSource.author.kind !== "member" + || sourceIds.has(rawSource.messageId) + ) throw new Error("invalid scoped source"); + let message = savedMessages.get(rawSource.messageId); + if (!message) throw new Error("missing scoped source message"); + validateSource(rawSource, message as Chat.Chat["entries"][number]); + sourceIds.add(rawSource.messageId); + } catch (error) { + throw new Error("hosted channel has an invalid scoped choice source", { + cause: error, + }); + } + } + } else if ( + Object.keys(value).some(key => + ![ + "questionnaireId", + "kind", + "generation", + "label", + "sourceMessageIds", + "suggestedOptionId", + ].includes(key) + ) + || typeof value.questionnaireId !== "string" + || !(value.questionnaireId === "document" || ULID.test(value.questionnaireId)) + || (value.kind !== "prompt" && value.kind !== "activity") + || (Object.hasOwn(value, "label") + && (typeof value.label !== "string" || value.label.length > 200)) + || (value.kind === "prompt" && ( + value.questionnaireId === "document" + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || (Object.hasOwn(value, "sourceMessageIds") && ( + !Array.isArray(value.sourceMessageIds) + || value.sourceMessageIds.length > 8 + || value.sourceMessageIds.some(id => typeof id !== "string" || !id || id.length > 200) + || new Set(value.sourceMessageIds).size !== value.sourceMessageIds.length + )) + || (Object.hasOwn(value, "suggestedOptionId") && ( + !Object.hasOwn(value, "sourceMessageIds") + || typeof value.suggestedOptionId !== "string" + || !ULID.test(value.suggestedOptionId) + )) + )) + || (value.kind === "activity" && ( + Object.hasOwn(value, "generation") || Object.hasOwn(value, "sourceMessageIds") + || Object.hasOwn(value, "suggestedOptionId") + )) + ) throw new Error("hosted channel has an invalid transcript entry"); + } +} + +export function validateScopedNotices( + transcript: Chat.Chat["entries"], + conversationPlan: ConversationPlan.State, + records: CardRecord[], +): void { + let scopedTriggers = new Set(); + for (let entry of transcript as unknown as Chat.Chat["entries"]) { + let notice = entry.decision; + if (notice?.kind !== "scoped-choice") continue; + let thread = conversationPlan.threads.find(item => item.id === notice.threadId); + let record = records.find(item => item.id === notice.cardId); + let proposalIndex = conversationPlan.events.findIndex(item => item.id === notice.proposalId); + let triggerIndex = conversationPlan.events.findIndex(item => item.id === notice.triggerEventId); + let proposal = conversationPlan.events[proposalIndex]; + let trigger = conversationPlan.events[triggerIndex]; + if ( + scopedTriggers.has(notice.triggerEventId) + || !thread || thread.questionnaireId !== notice.cardId + || !record || record.threadId !== notice.threadId + || notice.generation > record.history.length + || proposal?.type !== "scoped-choice.proposed" + || proposal.threadId !== notice.threadId || proposal.cardId !== notice.cardId + || proposal.optionId !== notice.optionId || proposal.label !== notice.label + || proposal.scope !== notice.scope + || triggerIndex < proposalIndex + || (trigger?.type !== "scoped-choice.proposed" + && trigger?.type !== "scoped-choice.agreed") + || trigger.type === "scoped-choice.proposed" && trigger.id !== proposal.id + || trigger.type === "scoped-choice.agreed" && ( + trigger.threadId !== notice.threadId || trigger.proposalId !== proposal.id + || trigger.cardId !== notice.cardId || trigger.optionId !== notice.optionId + || trigger.label !== notice.label || trigger.scope !== notice.scope + ) + || !isDeepStrictEqual(notice.sources, [ + proposal.source, + ...(trigger.type === "scoped-choice.agreed" ? [trigger.source] : []), + ]) + ) throw new Error("hosted channel has a scoped choice notice without accepted evidence"); + scopedTriggers.add(notice.triggerEventId); + } +} diff --git a/apps/server/src/plan/persistence.test.ts b/apps/server/src/plan/persistence.test.ts index 87c15c1c..d24bdf3f 100644 --- a/apps/server/src/plan/persistence.test.ts +++ b/apps/server/src/plan/persistence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode } from "lexical"; import * as Y from "yjs"; import * as Room from "./room"; @@ -55,13 +56,14 @@ describe("hosted plan persistence", () => { Service.source(plan), [], ); - let mutation = Room.insertDecision(peer, { - id: ulid(), - quote: "Durable first", - by: "octocat", - at: "2026-08-13T12:00:00.000Z", - notes: [{ by: "octocat", text: "Do not acknowledge early" }], - })!; + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("seed paragraph is missing"); + paragraph.append($createTextNode(" Durable first.")); + }, { discrete: true }); + await Room.settle(); + let update = Y.encodeStateAsUpdate(peer.doc, before); let frames: Array> = []; let ws = { data: { room: context.channel.id, handle: "octocat", client: "client", canEdit: true }, @@ -88,7 +90,7 @@ describe("hosted plan persistence", () => { rid: "request-1", id: "update-1", epoch: plan.document.epoch, - update: Buffer.from(mutation.update).toString("base64"), + update: Buffer.from(update).toString("base64"), }; Service.submit(plan, ws, message); diff --git a/apps/server/src/plan/planner-jobs-failure.test.ts b/apps/server/src/plan/planner-jobs-failure.test.ts new file mode 100644 index 00000000..d4c8ec92 --- /dev/null +++ b/apps/server/src/plan/planner-jobs-failure.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { storedRunning } from "./planner-jobs.test-fixtures"; + +test("failed interruption commit rejects open and the next open retries it", async () => { + let context = await storedRunning(); + let original = context.storage.collaboration.commit; + let fail = true; + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("storage unavailable"); + let afterFailure = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let saved = afterFailure.sidecar ?? afterFailure.snapshot!.sidecar; + expect( + (saved as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("running"); + expect(fatals).toHaveLength(1); + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + let afterRetry = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let retried = afterRetry.sidecar ?? afterRetry.snapshot!.sidecar; + expect( + (retried as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("failed"); + } finally { + await Service.close(opened); + } +}); + +test("a second reopen does not recommit an already interrupted job", async () => { + let context = await storedRunning(); + let first = await Service.open(context.channel.id, context.backend, context.server); + await Service.close(first); + let committed = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + return original(input); + }; + let second = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(second.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + expect(commits).toBe(0); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect(after.channel.revision).toBe(committed.channel.revision); + } finally { + await Service.close(second); + } +}); diff --git a/apps/server/src/plan/planner-jobs-restoration.test.ts b/apps/server/src/plan/planner-jobs-restoration.test.ts new file mode 100644 index 00000000..7722e87c --- /dev/null +++ b/apps/server/src/plan/planner-jobs-restoration.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { storedRunning } from "./planner-jobs.test-fixtures"; + +test("a missing Planner job field restores as empty and stays optional", async () => { + let context = await openPlan(); + try { + expect(context.plan.conversationPlanJobs).toEqual([]); + await Service.persist(context.plan); + let saved = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = saved.sidecar ?? saved.snapshot!.sidecar; + expect(Object.hasOwn(sidecar as object, "conversationPlanJobs")).toBe(false); + } finally { + await Service.close(context.plan); + } +}); + +test("observational stored reads leave a running Planner job untouched", async () => { + let context = await storedRunning(); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + return original(input); + }; + await Service.readStored(loaded); + expect(commits).toBe(0); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = after.sidecar ?? after.snapshot!.sidecar; + expect( + (sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("running"); +}); + +test("one malformed Planner job rejects the entire saved sidecar", async () => { + let context = await openPlan(); + await Service.close(context.plan); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.conversationPlanJobs = [{ id: "bad" }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "bad-planner-job", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("invalid Planner job"); +}); + +test("archived documents durably interrupt running jobs during maintenance open", async () => { + let context = await storedRunning(); + await context.storage.channels.archive({ id: context.channel.id, now: context.now }); + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect( + (sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("failed"); + } finally { + await Service.close(opened); + } +}); diff --git a/apps/server/src/plan/planner-jobs.test-fixtures.ts b/apps/server/src/plan/planner-jobs.test-fixtures.ts new file mode 100644 index 00000000..976e6097 --- /dev/null +++ b/apps/server/src/plan/planner-jobs.test-fixtures.ts @@ -0,0 +1,23 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; + +export const AT = "2026-09-25T10:00:00.000Z"; + +export let running: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: AT, +}; + +export async function storedRunning(extra: ConversationPlan.Job[] = []) { + let context = await openPlan(); + context.plan.conversationPlanJobs = [running, ...extra]; + await Service.persist(context.plan); + await Service.close(context.plan); + return context; +} diff --git a/apps/server/src/plan/planner-jobs.test.ts b/apps/server/src/plan/planner-jobs.test.ts new file mode 100644 index 00000000..78f2ceb4 --- /dev/null +++ b/apps/server/src/plan/planner-jobs.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { AT, storedRunning } from "./planner-jobs.test-fixtures"; + +test("restart durably records an interrupted Planner job before opening", async () => { + let context = await storedRunning([{ + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "done", + attempts: 1, + output: "{}", + at: AT, + }]); + let before = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let beforeSidecar = (before.sidecar ?? before.snapshot!.sidecar) as { + revision: number; + documentSeq: number; + }; + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs.map(job => [job.status, job.reason])).toEqual([ + ["failed", "interrupted"], + ["done", undefined], + ]); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = after.sidecar ?? after.snapshot!.sidecar; + expect((sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs.map(job => job.status)).toEqual(["failed", "done"]); + expect((sidecar as { documentSeq: number }).documentSeq).toBe(beforeSidecar.documentSeq); + expect((sidecar as { revision: number }).revision).toBe(beforeSidecar.revision); + expect(opened.document.seq).toBe(beforeSidecar.documentSeq); + expect(opened.revision).toBe(beforeSidecar.revision); + } finally { + await Service.close(opened); + } +}); + +test("open waits for interrupted-job durability before exposing the plan", async () => { + let context = await storedRunning(); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + context.storage.collaboration.commit = async input => { + entered.resolve(); + await release.promise; + return original(input); + }; + let returned = false; + let opening = Service.open(context.channel.id, context.backend, context.server).then(plan => { + returned = true; + return plan; + }); + await entered.promise; + let held = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let heldSidecar = held.sidecar ?? held.snapshot!.sidecar; + let jobs = (heldSidecar as { conversationPlanJobs: ConversationPlan.Job[] }).conversationPlanJobs; + expect(jobs[0]?.status).toBe("running"); + expect(returned).toBe(false); + release.resolve(); + let opened = await opening; + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + } finally { + await Service.close(opened); + } +}); diff --git a/apps/server/src/plan/projection-submit.test-fixtures.ts b/apps/server/src/plan/projection-submit.test-fixtures.ts new file mode 100644 index 00000000..ab438f02 --- /dev/null +++ b/apps/server/src/plan/projection-submit.test-fixtures.ts @@ -0,0 +1,88 @@ +import { ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import type { Socket } from "../wire"; + +export function socket( + context: Awaited>, + frames: Array>, +) { + return { + data: { room: context.channel.id, handle: "octocat", client: "client", canEdit: true }, + send(value: string) { + frames.push(JSON.parse(value) as Record); + }, + publish(_topic: string, value: string) { + frames.push(JSON.parse(value) as Record); + }, + close() {}, + } as unknown as Socket; +} + +export async function submitUpdate( + context: Awaited>, + plan: Service.Plan, + peer: Room.Document, + update: Uint8Array, + frames: Array>, +): Promise { + Service.submit(plan, socket(context, frames), { + kind: "plan:update", + ts: 0, + rid: ulid(), + id: ulid(), + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + peer.doc.destroy(); +} + +export async function seededPlan(context: Awaited>) { + let plan = await Service.open(context.channel.id, context.backend, context.server); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + Service.source(plan), + [], + ); + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("seed paragraph is missing"); + paragraph.append($createTextNode("Neighbor prose.")); + }, { discrete: true }); + await Room.settle(); + await submitUpdate( + context, + plan, + peer, + Y.encodeStateAsUpdate(peer.doc, before), + [], + ); + return plan; +} + +export async function submitResearchUpdate( + context: Awaited>, + plan: Service.Plan, + update: Uint8Array, + rid: string, +) { + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid, + id: rid, + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + return replies; +} diff --git a/apps/server/src/plan/projections.test.ts b/apps/server/src/plan/projections.test.ts new file mode 100644 index 00000000..7451095c --- /dev/null +++ b/apps/server/src/plan/projections.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { parse, serialize } from "@chopin/dialect"; +import { + newResearchProjections, + protectProjections, + removedResearchProjections, +} from "./projections"; + +let nodes = (source: string) => parse(source).children; + +test.each([ + '\n\n\n\n\n\n', + '> \n\n', +])("nested protected identities cannot collide across kinds: %s", source => { + let proposed = nodes(source); + expect(protectProjections([], proposed)).toContain("appears twice"); + expect(protectProjections(proposed, [])).toContain("appears twice"); + expect(newResearchProjections([], proposed)).toEqual([]); + expect(removedResearchProjections(proposed, [])).toEqual([]); +}); + +test.each([ + "\n\n\n\n", + '', + "> ", +])("missing or empty identity fails closed in either snapshot: %s", source => { + let malformed = nodes(source); + expect(protectProjections([], malformed)).toContain("missing its id"); + expect(protectProjections(malformed, [])).toContain("missing its id"); + expect(newResearchProjections([], malformed)).toEqual([]); + expect(removedResearchProjections(malformed, [])).toEqual([]); +}); + +test.each(["Questionnaire", "Decision"])( + "Research exceptions never authorize new or removed %s", + kind => { + let projection = nodes(`<${kind} id="card" />`); + let ids = new Set(["card"]); + expect(protectProjections([], projection, ids, ids)).toContain("cannot be authored"); + expect(protectProjections(projection, [], ids, ids)).toContain("cannot be dropped"); + }, +); + +test.each([ + ['', ''], + [ + 'Original', + 'Changed', + ], + ['', ''], +])("same identity cannot conceal a changed payload or kind: %s", (before, after) => { + let ids = new Set(["card"]); + expect(protectProjections(nodes(before), nodes(after), ids, ids)).toContain("cannot be altered"); +}); + +test("exact nested projections may move while unrelated prose changes", () => { + let card = 'Original choice'; + let before = nodes(`Before.\n\n\n\n${card}\n\n\n\n`); + let after = nodes(`\n\nChanged prose.\n\n${card}`); + let canonical = serialize({ type: "root", children: after }); + expect(canonical).toContain("Changed prose."); + expect(protectProjections(before, after)).toBeUndefined(); + expect(newResearchProjections(before, after)).toEqual([]); + expect(removedResearchProjections(before, after)).toEqual([]); +}); + +test("Research additions and removals require the exact IDs in the correct authority sets", () => { + let before = nodes(''); + let after = nodes(''); + expect(newResearchProjections(before, after)).toEqual(["new"]); + expect(removedResearchProjections(before, after)).toEqual(["old"]); + expect(protectProjections(before, after)).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]))).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(), new Set(["old"]))) + .toContain("cannot be authored"); + expect(protectProjections(before, after, new Set(["old"]), new Set(["new"]))) + .toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))) + .toBeUndefined(); +}); diff --git a/apps/server/src/plan/projections.ts b/apps/server/src/plan/projections.ts new file mode 100644 index 00000000..c2ef24a8 --- /dev/null +++ b/apps/server/src/plan/projections.ts @@ -0,0 +1,90 @@ +import { serialize } from "@chopin/dialect/serialize"; + +import type { RootContent } from "mdast"; + +const PROTECTED = new Set(["Questionnaire", "Decision", "Research"]); + +function collect(nodes: RootContent[]): Map | string { + let found = new Map(); + let walk = (node: RootContent): string | undefined => { + if ( + (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") + && node.name && PROTECTED.has(node.name) + ) { + let id = node.attributes.find(attribute => + attribute.type === "mdxJsxAttribute" && attribute.name === "id" + && typeof attribute.value === "string" + ); + if (!id || typeof id.value !== "string" || !id.value) { + return `a ${node.name} projection is missing its id`; + } + if (found.has(id.value)) return `\`${id.value}\` appears twice`; + found.set(id.value, { + type: node.name, + source: serialize({ type: "root", children: [node] }), + }); + } + if ("children" in node && Array.isArray(node.children)) { + for (let child of node.children) { + let failure = walk(child as RootContent); + if (failure) return failure; + } + } + return undefined; + }; + for (let node of nodes) { + let failure = walk(node); + if (failure) return failure; + } + return found; +} + +/** IDs of new atomic references whose records must be checked before accepting a browser batch. */ +export function newResearchProjections(base: RootContent[], next: RootContent[]): string[] { + let current = collect(base); + let proposed = collect(next); + if (typeof current === "string" || typeof proposed === "string") return []; + return [...proposed].flatMap(([id, value]) => + value.type === "Research" && !current.has(id) ? [id] : [] + ); +} + +/** Existing research references removed from a browser batch need terminal request authority. */ +export function removedResearchProjections(base: RootContent[], next: RootContent[]): string[] { + let current = collect(base); + let proposed = collect(next); + if (typeof current === "string" || typeof proposed === "string") return []; + return [...current].flatMap(([id, value]) => + value.type === "Research" && !proposed.has(id) ? [id] : [] + ); +} + +/** Protected components may move, but browser and Planner prose edits cannot change their records. */ +export function protectProjections( + base: RootContent[], + next: RootContent[], + allowedResearch = new Set(), + removableResearch = new Set(), +): string | undefined { + let current = collect(base); + if (typeof current === "string") return current; + let proposed = collect(next); + if (typeof proposed === "string") return proposed; + for (let [id, value] of current) { + let replacement = proposed.get(id); + if (!replacement) { + if (value.type === "Research" && removableResearch.has(id)) continue; + return "Existing Questionnaire, Decision, and Research projections cannot be dropped."; + } + if (replacement.type !== value.type || replacement.source !== value.source) { + return "Existing Questionnaire, Decision, and Research projections cannot be altered."; + } + } + for (let id of proposed.keys()) { + if (!current.has(id)) { + if (proposed.get(id)?.type === "Research" && allowedResearch.has(id)) continue; + return "Questionnaire, Decision, and Research projections cannot be authored by rewriting the plan."; + } + } + return undefined; +} diff --git a/apps/server/src/plan/protected-projection.test-fixtures.ts b/apps/server/src/plan/protected-projection.test-fixtures.ts new file mode 100644 index 00000000..c748bc9b --- /dev/null +++ b/apps/server/src/plan/protected-projection.test-fixtures.ts @@ -0,0 +1,111 @@ +import { DecisionNode, QuestionnaireNode, ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import * as Questions from "../questions/service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { seededPlan, socket } from "./projection-submit.test-fixtures"; + +export async function protectedFixture(kind: "decision" | "questionnaire") { + let context = await hosted(); + let plan = await seededPlan(context); + let id = ulid(); + if (kind === "decision") { + let quote = "Neighbor prose."; + let passage = Room.passageAt(plan.document, [0], quote, 0, quote.length); + let note = { by: "octocat", text: "Keep the original wording." }; + plan.threads.set(id, { + id, + status: "accepted", + passage, + notes: [{ id: ulid(), handle: "octocat", text: note.text, ts: 1 }], + quote, + resolver: "octocat", + at: Math.floor(Date.now() / 1_000), + }); + let mutation = Room.insertDecision(plan.document, { + id, + quote, + by: "octocat", + at: new Date().toISOString(), + notes: [note], + }); + if (!mutation) throw new Error("decision projection was not inserted"); + await Service.publish(plan, context.server, context.channel.id, mutation); + } else { + let optionId = ulid(); + id = await Questions.insertConversationCard(plan, context.server, context.channel.id, { + threadId: ulid(), + header: "Hosting", + question: "Where should we host?", + options: [{ id: optionId, label: "S3" }], + }); + let replies: Array> = []; + let ws = socket(context, replies); + let entry = plan.questions.open.get(id)!; + let questionId = entry.definition.questions[0]!.id; + let human = entry.model.fork(); + human.api.val([questionId, "choice"]).set(optionId); + let patch = human.api.flush(); + if (!patch) throw new Error("answer choice made no patch"); + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: ulid(), + id, + patch: [...patch.toBinary()], + }); + await Questions.submit(plan, context.server, context.channel.id, ws, { + kind: "question:submit", + ts: 0, + rid: ulid(), + id, + revision: plan.questions.open.get(id)!.revision, + }); + if (plan.records.get(id)?.status !== "answered") { + throw new Error(`questionnaire sidecar did not become answered: ${JSON.stringify(replies)}`); + } + } + return { context, plan, id }; +} + +export async function changeProjection( + peer: Room.Document, + kind: "decision" | "questionnaire", + id: string, + change: "remove" | "alter" | "move-and-edit" | "remove-and-edit" | "alter-and-edit", +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + if (kind === "decision") { + let node = $nodesOfType(DecisionNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("decision projection is missing from peer"); + if (change === "remove" || change === "remove-and-edit") node.remove(); + else if (change === "alter" || change === "alter-and-edit") { + node.setDecision({ ...node.getDecision(), quote: "Forged quote." }); + } + if (change !== "remove" && change !== "alter") { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("neighbor paragraph is missing"); + paragraph.append($createTextNode(" Edited.")); + if (change === "move-and-edit") paragraph.insertBefore(node); + } + } else { + let node = $nodesOfType(QuestionnaireNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("questionnaire projection is missing from peer"); + if (change === "remove" || change === "remove-and-edit") node.remove(); + else if (change === "alter" || change === "alter-and-edit") { + node.setQuestionnaire({ ...node.getQuestionnaire(), status: "open" }); + } + if (change !== "remove" && change !== "alter") { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("neighbor paragraph is missing"); + paragraph.append($createTextNode(" Edited.")); + if (change === "move-and-edit") paragraph.insertBefore(node); + } + } + }, { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} diff --git a/apps/server/src/plan/protected-projection.test.ts b/apps/server/src/plan/protected-projection.test.ts new file mode 100644 index 00000000..8a5a7bdc --- /dev/null +++ b/apps/server/src/plan/protected-projection.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { socket } from "./projection-submit.test-fixtures"; +import { changeProjection, protectedFixture } from "./protected-projection.test-fixtures"; + +describe("hosted plan persistence", () => { + for (let kind of ["decision", "questionnaire"] as const) { + for (let change of ["remove", "alter"] as const) { + it(`rejects a browser ${change} of an accepted ${kind} projection before ack`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await changeProjection(peer, kind, id, change); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "protected-projection", + id: "protected-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + if (kind === "decision") { + expect(plan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(plan.records.get(id)?.status).toBe("answered"); + } + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + } + + for (let change of ["remove-and-edit", "alter-and-edit"] as const) { + it(`rejects a prose edit bundled with ${change} of an accepted ${kind}`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let originalEpoch = plan.document.epoch; + let peer = await Room.restore( + originalEpoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + let activePlan = plan; + try { + let update = await changeProjection(peer, kind, id, change); + let replies: Array> = []; + Service.submit(activePlan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "combined-projection-edit", + id: "combined-projection-edit", + epoch: originalEpoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await activePlan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(activePlan.document.epoch).not.toBe(originalEpoch); + expect(Service.source(activePlan)).toBe(originalSource); + expect(Service.source(activePlan)).not.toContain("Edited."); + if (kind === "decision") { + expect(activePlan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(activePlan.records.get(id)?.status).toBe("answered"); + } + + await Service.close(activePlan); + activePlan = await Service.open( + context.channel.id, + context.backend, + context.server, + ); + expect(Service.source(activePlan)).toBe(originalSource); + if (kind === "decision") { + expect(activePlan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(activePlan.records.get(id)?.status).toBe("answered"); + } + } finally { + peer.doc.destroy(); + await Service.close(activePlan); + } + }); + } + + it(`accepts moving an exact ${kind} projection with adjacent prose edits`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await changeProjection(peer, kind, id, "move-and-edit"); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "move-projection", + id: "move-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(true); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(false); + let source = Service.source(plan); + expect(source).toContain("Edited."); + expect(source).toContain(`id="${id}"`); + expect(source.indexOf(kind === "decision" ? " { + it("fills legacy defaults and persists them through open, close and reopen", async () => { + let context = await stored([legacyRecord()], [draft()]); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")).toMatchObject({ + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }); + expect(Store.snapshot(plan.questions, "saved-card")).toMatchObject({ + open: true, + definition: definition(), + }); + } finally { + await Service.close(plan); + } + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.sidecar).toMatchObject({ + questions: [{ origin: "planner", history: [], optionOrigins: {}, editors: [] }], + }); + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.definition).toEqual(definition()); + } finally { + await Service.close(reopened); + } + }); + + it("persists owner and decision time derived from a legacy answered record", async () => { + let record = { + ...legacyRecord(), + status: "answered", + answers: { "saved-question": "Cloud" }, + resolver: "ana", + at: 123, + }; + let context = await stored([record], []); + try { + expect(context.plan.records.get("saved-card")).toMatchObject({ + ...record, + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + owner: "ana", + decidedAt: 123, + }); + } finally { + await Service.close(context.plan); + } + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.sidecar).toMatchObject({ questions: [{ owner: "ana", decidedAt: 123 }] }); + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")).toMatchObject({ owner: "ana", decidedAt: 123 }); + } finally { + await Service.close(reopened); + } + }); + + it("restores open and reopened pending definitions with their original empty draft", async () => { + for (let status of ["open", "reopened"] as const) { + let value = definition(); + value.questions[0]!.options = []; + let record = { + ...legacyRecord(value), + status, + origin: "conversation" as const, + threadId: "saved-thread", + history: [], + optionOrigins: {}, + editors: [], + }; + let context = await stored([record], [draft(value)]); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")).toMatchObject(record); + let entry = Store.get(plan.questions, "saved-card")!; + expect(Question.read(entry.model, entry.definition)["saved-question"]).toEqual({ + mode: "choices", + choice: null, + custom: "", + options: {}, + }); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.status).toBe(status); + } finally { + await Service.close(reopened); + } + } + }); + + it("restores a reopened card's suggestion without changing the shared draft", async () => { + let suggested = { optionId: "saved-b", messageIds: ["message-1"], revision: 2 }; + let context = await stored([{ ...legacyRecord(), status: "reopened" }], [{ + ...draft(), + revision: 2, + suggested, + }]); + let plan = context.plan; + try { + let entry = Store.get(plan.questions, "saved-card")!; + expect(entry.suggested).toEqual(suggested); + expect(Question.read(entry.model, entry.definition)["saved-question"]?.choice).toBeNull(); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(Store.get(reopened.questions, "saved-card")?.suggested).toEqual(suggested); + } finally { + await Service.close(reopened); + } + }); + + it("rejects malformed durable records and noncanonical saved definitions", async () => { + for ( + let change of [ + { origin: "unknown" }, + { editors: ["ana", "ana"] }, + { choices: ["missing-option"] }, + { history: [{ choices: [], owner: "ana", at: 1 }] }, + { definition: { questions: [{ ...definition().questions[0]!, header: "" }] } }, + ] + ) { + expect(await rejected(stored([{ ...legacyRecord(), ...change }], [draft()]))).toBeInstanceOf( + Error, + ); + } + }); + + it("rejects an orphan draft rather than restoring it without its record", async () => { + expect(await rejected(stored([], [draft()]))).toBeInstanceOf(Error); + }); + + it("requires identical saved question IDs, option IDs, order and display text", async () => { + for ( + let field of [ + "question-id", + "question", + "header", + "multiple", + "option-id", + "label", + "description", + "order", + ] + ) { + let value = definition(); + let question = value.questions[0]!; + if (field === "question-id") question.id = "different-question"; + if (field === "question") question.question = "A different question?"; + if (field === "header") question.header = "Different header"; + if (field === "multiple") question.multiple = true; + if (field === "option-id") question.options[0]!.id = "different-option"; + if (field === "label") question.options[0]!.label = "Different label"; + if (field === "description") question.options[0]!.description = "Different description"; + if (field === "order") question.options.reverse(); + expect(await rejected(stored([legacyRecord()], [draft(value)]))).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/server/src/plan/question-record-sources.test.ts b/apps/server/src/plan/question-record-sources.test.ts new file mode 100644 index 00000000..3c33b3fa --- /dev/null +++ b/apps/server/src/plan/question-record-sources.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +import { applyInference, initialState } from "../conversation-plan/domain"; +import { draft, legacyRecord, rejected, stored } from "./question-record.test-fixtures"; + +// Saved option-source checks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/plan/service.ts; no classifier or external service is involved. +function sourced() { + let message = { + id: "message-1", + text: "Try 🧪 Cloud next", + ts: 1, + author: { kind: "member" as const, handle: "ana" }, + }; + let source = { + messageId: message.id, + author: message.author, + quote: "🧪 Cloud", + start: 4, + end: 12, + role: "option" as const, + }; + let record = { + ...legacyRecord(), + optionOrigins: { "saved-a": { origin: "chat" as const, source } }, + }; + return { message, source, record }; +} + +describe("durable option source restoration", () => { + it("checks a member's exact UTF-16 quote and preserves its source through close", async () => { + let { message, source, record } = sourced(); + let context = await stored( + [{ + ...record, + optionOrigins: { "saved-a": { origin: "planner", source } }, + }], + [draft()], + [message], + ); + let plan = context.plan; + try { + await Service.exclusive(plan, async () => { + let threadId = "source-thread"; + let state = applyInference(initialState(), { + id: "source-opening", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + ...source, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }, message); + plan.conversationPlan = applyInference(state, { + id: "source-option", + type: "option.added", + threadId, + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source, + contribution: { + id: "saved-a", + text: source.quote, + authoring: "quoted", + targetId: threadId, + }, + }, message); + let saved = plan.records.get("saved-card")!; + saved.threadId = threadId; + saved.optionOrigins = record.optionOrigins; + await Service.persistExclusive(plan); + }); + expect(plan.records.get("saved-card")?.optionOrigins).toEqual(record.optionOrigins); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.optionOrigins).toEqual(record.optionOrigins); + } finally { + await Service.close(reopened); + } + }); + + it("accepts an agent source only when the saved author is also the agent", async () => { + let { message, source, record } = sourced(); + let author = { kind: "agent" as const }; + let context = await stored( + [{ + ...record, + optionOrigins: { "saved-a": { origin: "planner", source: { ...source, author } } }, + }], + [draft()], + [{ ...message, author }], + ); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")?.optionOrigins["saved-a"]?.source?.author).toEqual( + author, + ); + } finally { + await Service.close(plan); + } + }); + + it("rejects absent, changed, streaming, or wrongly attributed saved messages", async () => { + let { message, record } = sourced(); + for ( + let transcript of [ + [], + [{ ...message, text: "Try another host next" }], + [{ ...message, streaming: true }], + [{ ...message, author: { kind: "member" as const, handle: "ben" } }], + [{ ...message, author: { kind: "agent" } }], + ] + ) { + expect(await rejected(stored([record], [draft()], transcript))).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/server/src/plan/question-record.test-fixtures.ts b/apps/server/src/plan/question-record.test-fixtures.ts new file mode 100644 index 00000000..d43b0683 --- /dev/null +++ b/apps/server/src/plan/question-record.test-fixtures.ts @@ -0,0 +1,55 @@ +import * as Question from "@chopin/question"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; + +import type { Definition } from "@chopin/question"; + +export function definition(): Definition { + return { + questions: [{ + id: "saved-question", + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [ + { id: "saved-a", label: "Cloud", description: "Managed" }, + { id: "saved-b", label: "Own server", description: "" }, + ], + }], + }; +} + +export function draft(value = definition()) { + return { + id: "saved-card", + definition: value, + model: [...Question.create(value).toBinary()], + revision: 0, + }; +} + +export function legacyRecord(value = definition()) { + return { id: "saved-card", definition: value, status: "open" }; +} + +export async function stored( + questions: unknown[], + openQuestions: unknown[], + transcript: unknown[] = [], +) { + let context = await openPlan("# Questions\n", { questions, openQuestions, transcript }); + return { + ...context, + open: () => Service.open(context.channel.id, context.backend, context.server), + }; +} + +export async function rejected(pending: ReturnType) { + try { + let context = await pending; + await Service.close(context.plan); + return undefined; + } catch (err) { + return err; + } +} diff --git a/apps/server/src/plan/questionnaires.ts b/apps/server/src/plan/questionnaires.ts index cf7568ed..c394af98 100644 --- a/apps/server/src/plan/questionnaires.ts +++ b/apps/server/src/plan/questionnaires.ts @@ -16,6 +16,8 @@ export type QuestionnairePlacement = { id: string; at: BlockAddress; after?: string; + /** Replace the decorator while moving it so an adjacent prose caret keeps its Yjs type. */ + renew?: boolean; }; type Actions = { @@ -99,8 +101,12 @@ export function place( let previous = placement.after ? questionnaires.get(placement.after) ?? prose : prose; if (questionnaire === previous || questionnaire.getPreviousSibling() === previous) continue; + let moved = placement.renew + ? $createQuestionnaireNode(questionnaire.getQuestionnaire()) + : questionnaire; questionnaire.remove(); - previous.insertAfter(questionnaire); + previous.insertAfter(moved); + questionnaires.set(placement.id, moved); changed = true; } diff --git a/apps/server/src/plan/research-placement-1.test.ts b/apps/server/src/plan/research-placement-1.test.ts new file mode 100644 index 00000000..9e258d54 --- /dev/null +++ b/apps/server/src/plan/research-placement-1.test.ts @@ -0,0 +1,39 @@ +import { expect, it, spyOn } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +it("persists a server-authored Research card once before broadcasting", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + try { + let id = ulid(); + await Service.placeResearchReference(plan, id); + expect(Service.source(plan)).toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + if (!stored) throw new Error("document is missing"); + expect((await Service.readStored(stored)).source).toContain(``); + await Service.placeResearchReference(plan, id); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + } finally { + await Service.close(plan); + } +}); + +it("does not expose a Research card when its document commit fails", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let commit = spyOn(context.storage.collaboration, "commit").mockImplementation(async () => { + throw new Error("document commit failed"); + }); + try { + await expect(Service.placeResearchReference(plan, ulid())) + .rejects.toThrow("document commit failed"); + expect(Service.source(plan)).not.toContain(" frame.kind === "plan:update")).toEqual([]); + } finally { + commit.mockRestore(); + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/research-placement-2.test.ts b/apps/server/src/plan/research-placement-2.test.ts new file mode 100644 index 00000000..d1796c43 --- /dev/null +++ b/apps/server/src/plan/research-placement-2.test.ts @@ -0,0 +1,57 @@ +import { expect, it, spyOn } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +it("holds a Research card from both clients until its durable commit finishes", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let first: string[] = []; + let second: string[] = []; + let publish = spyOn(context.server, "publish").mockImplementation((_topic, value) => { + first.push(String(value)); + second.push(String(value)); + return 0; + }); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commitOriginal = context.storage.collaboration.commit; + let commit = spyOn(context.storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commitOriginal(input); + }); + try { + let placement = Service.placeResearchReference(plan, ulid()); + await entered.promise; + expect(first).toEqual([]); + expect(second).toEqual([]); + release.resolve(); + await placement; + expect(first).toHaveLength(1); + expect(second).toEqual(first); + } finally { + commit.mockRestore(); + publish.mockRestore(); + await Service.close(plan); + } +}); + +it("defers Research placement while implementation holds the document", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let id = ulid(); + try { + plan.claiming = true; + expect(await Service.placeResearchReference(plan, id)).toBe("deferred"); + expect(Service.source(plan)).not.toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toEqual([]); + plan.claiming = false; + expect(await Service.placeResearchReference(plan, id)).toBe("placed"); + expect(Service.source(plan)).toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + } finally { + plan.claiming = false; + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/research-placement.test-fixtures.ts b/apps/server/src/plan/research-placement.test-fixtures.ts new file mode 100644 index 00000000..a2bf769f --- /dev/null +++ b/apps/server/src/plan/research-placement.test-fixtures.ts @@ -0,0 +1,24 @@ +import { ulid } from "@chopin/dialect"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +export async function pendingInlineResearch( + context: Awaited>, + channelId = context.channel.id, +) { + let id = ulid(); + return context.storage.research.start({ + id, + channelId, + title: "Research request", + question: "What should we research?", + origin: "inline", + createdBy: "U_octocat", + turnId: ulid(), + messageId: ulid(), + requestId: ulid(), + idempotencyKey: `inline-research-${id}`, + fingerprint: `inline-research-${id}`, + now: context.now, + lease: context.lease, + }); +} diff --git a/apps/server/src/plan/research-projection-authority.test.ts b/apps/server/src/plan/research-projection-authority.test.ts new file mode 100644 index 00000000..0b540a84 --- /dev/null +++ b/apps/server/src/plan/research-projection-authority.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { ulid } from "@chopin/dialect"; +import { socket, submitResearchUpdate } from "./projection-submit.test-fixtures"; +import { insertResearchProjection } from "./research-projection.test-fixtures"; +import { linkedResearchJob, setResearchJobState } from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("rejects a Research projection for an inline request with no linked work", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate( + context, + plan, + update, + "unlinked-research-projection", + ); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + let stored = await context.storage.research.get(context.channel.id, request.workspace.id); + expect(stored?.workspace.origin).toBe("inline"); + expect(stored?.turns[0]?.kind).toBe("initial"); + expect(stored?.turns[0]?.evidenceJobId).toBeUndefined(); + expect(stored?.turns[0]?.answerJobId).toBeUndefined(); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + it("rejects a Research projection backed by a request from another channel", async () => { + let context = await hosted(); + let otherChannel = await context.storage.channels.create({ + id: ulid(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Other document", + createdBy: "U_octocat", + now: context.now, + }); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context, otherChannel.id); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "foreign-research-projection", + id: "foreign-research-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + expect(await context.storage.research.get(context.channel.id, request.workspace.id)) + .toBeUndefined(); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + it("rejects inserting a stale Research reference after its linked request failed", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + await setResearchJobState(context, job.id, "failed"); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate(context, plan, update, "stale-research-insert"); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); +}); diff --git a/apps/server/src/plan/research-projection-jobs.test-fixtures.ts b/apps/server/src/plan/research-projection-jobs.test-fixtures.ts new file mode 100644 index 00000000..57496274 --- /dev/null +++ b/apps/server/src/plan/research-projection-jobs.test-fixtures.ts @@ -0,0 +1,101 @@ +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +export async function linkedResearchJob( + context: Awaited>, + workspaceId: string, + role: "evidence" | "answer", +) { + let detail = await context.storage.research.get(context.channel.id, workspaceId); + let initial = detail?.turns.find(turn => turn.kind === "initial"); + if (!initial) throw new Error("initial research turn is missing"); + let type = `research-${role}`; + let jobId = ulid(); + let targetKey = `${type}:workspace:${workspaceId}:turn:${initial.id}:${role}`; + let created = await context.storage.jobs.enqueue({ + id: jobId, + channelId: context.channel.id, + type, + version: 1, + origin: "user", + targetKey, + idempotencyKey: `research-test-${jobId}`, + fingerprint: `research-test-${jobId}`, + input: { workspaceId, turnId: initial.id, query: initial.question }, + availableAt: context.now, + now: context.now, + lease: context.lease, + }); + await context.storage.research.linkJob({ + channelId: context.channel.id, + workspaceId, + turnId: initial.id, + role, + jobId: created.job.id, + now: context.now, + lease: context.lease, + }); + return created.job; +} + +export async function setResearchJobState( + context: Awaited>, + jobId: string, + state: "failed" | "cancelled" | "completed", +) { + let [claimed] = await context.storage.jobs.claim({ + channelId: context.channel.id, + claimOwner: `research-test-${jobId}`, + count: 1, + ttlMs: 30_000, + now: context.now, + lease: context.lease, + }); + if (!claimed) throw new Error("research job was not claimable"); + if (state === "failed") { + return context.storage.jobs.fail({ + channelId: context.channel.id, + jobId, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + reason: "test-failure", + now: context.now, + lease: context.lease, + }); + } + if (state === "completed") { + return context.storage.jobs.settle({ + channelId: context.channel.id, + jobId, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + artifact: { completed: true }, + now: context.now, + lease: context.lease, + }); + } + return context.storage.jobs.cancel({ + channelId: context.channel.id, + jobId, + now: context.now, + lease: context.lease, + }); +} + +export async function publishReadyResearch( + context: Awaited>, + workspaceId: string, +) { + let answer = await linkedResearchJob(context, workspaceId, "answer"); + await setResearchJobState(context, answer.id, "completed"); + await context.storage.research.publishInitialReport({ + channelId: context.channel.id, + workspaceId, + answerJobId: answer.id, + title: "Published research", + initial: await Service.initial("# Published research\n"), + now: context.now, + lease: context.lease, + }); +} diff --git a/apps/server/src/plan/research-projection-lifecycle.test.ts b/apps/server/src/plan/research-projection-lifecycle.test.ts new file mode 100644 index 00000000..a27e773e --- /dev/null +++ b/apps/server/src/plan/research-projection-lifecycle.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { submitResearchUpdate } from "./projection-submit.test-fixtures"; +import { + addResearchReference, + removeResearchProjection, +} from "./research-projection.test-fixtures"; +import { + linkedResearchJob, + publishReadyResearch, + setResearchJobState, +} from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("rejects removing an inline Research reference while linked work is active", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await addResearchReference( + context, + plan, + request.workspace.id, + "active-research-add", + ); + try { + let update = await removeResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate(context, plan, update, "active-research-remove"); + + expect((await context.storage.jobs.get(context.channel.id, job.id))?.job.state) + .toBe("pending"); + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toContain(``); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + for (let terminal of ["failed", "cancelled", "ready"] as const) { + it(`allows removing an inline Research reference after ${terminal} work`, async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let request = await pendingInlineResearch(context); + let evidence = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await addResearchReference( + context, + plan, + request.workspace.id, + `terminal-research-add-${terminal}`, + ); + if (terminal === "failed" || terminal === "cancelled") { + await setResearchJobState(context, evidence.id, terminal); + } else if (terminal === "ready") { + await setResearchJobState(context, evidence.id, "completed"); + await publishReadyResearch(context, request.workspace.id); + } + try { + let update = await removeResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate( + context, + plan, + update, + `terminal-research-remove-${terminal}`, + ); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(true); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(false); + expect(Service.source(plan)).not.toContain(``); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + } +}); diff --git a/apps/server/src/plan/research-projection-race.test.ts b/apps/server/src/plan/research-projection-race.test.ts new file mode 100644 index 00000000..4459fb5b --- /dev/null +++ b/apps/server/src/plan/research-projection-race.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { socket } from "./projection-submit.test-fixtures"; +import { insertResearchProjection } from "./research-projection.test-fixtures"; +import { linkedResearchJob, setResearchJobState } from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("resets a Research insertion when its job fails after validation", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let originalCommit = context.storage.collaboration.commit; + context.storage.collaboration.commit = input => { + if (!input.researchProjections?.length) return originalCommit(input); + entered.resolve(); + return release.promise.then(() => originalCommit(input)); + }; + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "racing-research-insert", + id: "racing-research-insert", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Promise.race([ + entered.promise, + Bun.sleep(1_000).then(() => { + throw new Error("Research commit did not start"); + }), + ]); + await setResearchJobState(context, job.id, "failed"); + release.resolve(); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:update")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.snapshot?.source).toBe(originalSource); + expect(saved?.updates).toHaveLength(0); + } finally { + release.resolve(); + context.storage.collaboration.commit = originalCommit; + peer.doc.destroy(); + await Service.close(plan); + } + }); +}); diff --git a/apps/server/src/plan/research-projection.test-fixtures.ts b/apps/server/src/plan/research-projection.test-fixtures.ts new file mode 100644 index 00000000..ee628223 --- /dev/null +++ b/apps/server/src/plan/research-projection.test-fixtures.ts @@ -0,0 +1,52 @@ +import { $createResearchNode, ResearchNode } from "@chopin/dialect"; +import { $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { submitResearchUpdate } from "./projection-submit.test-fixtures"; + +export async function insertResearchProjection( + peer: Room.Document, + id: string, +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => $getRoot().append($createResearchNode(id)), { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} + +export async function removeResearchProjection( + peer: Room.Document, + id: string, +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let node = $nodesOfType(ResearchNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("research projection is missing from peer"); + node.remove(); + }, { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} + +export async function addResearchReference( + context: Awaited>, + plan: Service.Plan, + workspaceId: string, + rid: string, +) { + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + Service.source(plan), + [], + ); + let update = await insertResearchProjection(peer, workspaceId); + let replies = await submitResearchUpdate(context, plan, update, rid); + if (!replies.some(frame => frame.kind === "plan:ack")) { + peer.doc.destroy(); + throw new Error("pending research reference was not accepted"); + } + return peer; +} diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index d1673765..0af53e56 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,19 +20,30 @@ import * as Y from "yjs"; import { $createDecisionNode, $createPlanNodes, - $exportPlan, + $createResearchNode, + $exportPlanTree, $importPlan, + assert, exportPlan, limits, parse, PlanValidationError, QuestionnaireNode, registry as buildRegistry, + ResearchNode, serialize, ulid, } from "@chopin/dialect"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; +import { + newResearchProjections, + protectProjections, + removedResearchProjections, +} from "./projections"; import * as Questionnaires from "./questionnaires"; +import { createCardProjections, QuestionnaireProjectionError } from "./card-projections"; +export { QuestionnaireProjectionError } from "./card-projections"; +export type { CardChange } from "./card-projections"; import { $createParagraphNode, $getNodeByKey, @@ -42,6 +53,7 @@ import { $nodesOfType, } from "lexical"; +import type { ResearchProjectionChange } from "../storage/model"; import type { Binding, Provider } from "@lexical/yjs"; import type { LexicalEditor, LexicalNode } from "lexical"; import type { Root, RootContent } from "mdast"; @@ -192,7 +204,13 @@ export function project(target: Document): string { let failure: unknown; target.editor.getEditorState().read(() => { try { - source = $exportPlan({ registry: schema() }); + let tree = $exportPlanTree({ registry: schema() }); + // Empty root paragraphs hold a shared caret but are not addressable MDX blocks. + tree.children = tree.children.filter(node => + node.type !== "paragraph" || node.children.length > 0 + ); + assert(tree); + source = serialize(tree); } catch (err) { failure = err; } @@ -278,8 +296,7 @@ export async function restore( try { Y.applyUpdate(restored.doc, checkpoint, REMOTE); await settle(); - // Checkpoints written before empty paragraphs were dropped from projection - // store their former blank lines; the canonical form is the same document. + // Older checkpoints retain blank lines from empty caret paragraphs. let projected = project(restored); if (projected !== source && projected !== serialize(parse(source))) { throw new Error("stored plan source does not match its Yjs checkpoint"); @@ -321,7 +338,7 @@ export function mark(target: Document): void { } export type Applied = - | { ok: true; seq: number } + | { ok: true; seq: number; researchProjections: ResearchProjectionChange[] } | { ok: false; issues: string[] }; /** @@ -331,21 +348,50 @@ export type Applied = * document proves the shared tree still serialises to something the dialect * accepts, and doing that per keystroke would be absurd. */ -export async function apply(target: Document, updates: Uint8Array[]): Promise { - if (updates.length === 0) return { ok: true, seq: target.seq }; +export async function apply( + target: Document, + updates: Uint8Array[], + authorizeResearch?: (id: string, action: "add" | "remove") => Promise, +): Promise { + if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; + let before = parse(project(target)).children; + let researchProjections: ResearchProjectionChange[] = []; for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE); await settle(); try { - project(target); + let after = parse(project(target)).children; + let added = new Set(); + let removed = new Set(); + for ( + let [action, ids, allowed] of [ + ["add", newResearchProjections(before, after), added], + ["remove", removedResearchProjections(before, after), removed], + ] as const + ) { + for (let id of ids) { + let authorized = false; + try { + authorized = !!authorizeResearch && await authorizeResearch(id, action); + } catch { + return { ok: false, issues: ["research-reference-unverified"] }; + } + if (!authorized) return { ok: false, issues: ["protected-projection"] }; + allowed.add(id); + researchProjections.push({ id, action }); + } + } + if (protectProjections(before, after, added, removed)) { + return { ok: false, issues: ["protected-projection"] }; + } } catch (err) { if (!(err instanceof PlanValidationError)) throw err; return { ok: false, issues: err.issues.map(issue => issue.code) }; } target.seq += updates.length; - return { ok: true, seq: target.seq }; + return { ok: true, seq: target.seq, researchProjections }; } /** @@ -426,6 +472,36 @@ function mutate(target: Document, change: () => boolean): Mutation | undefined { export type QuestionnaireInsertion = Questionnaires.QuestionnaireInsertion; +/** Snapshot visible card projections for a startup migration. */ +export function questionnaireProjections(target: Document): Questionnaire[] { + let values: Questionnaire[] = []; + target.editor.getEditorState().read(() => { + values = $nodesOfType(QuestionnaireNode).map(node => structuredClone(node.getQuestionnaire())); + }); + return values; +} + +/** Add thread links to previously unlinked visible cards in one document update. */ +export function linkQuestionnaireThreads( + target: Document, + links: Array<{ id: string; threadId: string }>, +): Mutation | undefined { + return mutate(target, () => { + let nodes = $nodesOfType(QuestionnaireNode); + for (let link of links) { + let matches = nodes.filter(node => node.getId() === link.id); + if (matches.length !== 1 || matches[0]!.getQuestionnaire().thread) { + throw new QuestionnaireProjectionError("Card is not available for linking"); + } + } + for (let link of links) { + let node = nodes.find(node => node.getId() === link.id)!; + node.setQuestionnaire({ ...node.getQuestionnaire(), thread: link.threadId }); + } + return links.length > 0; + }); +} + export function insertQuestionnaires( target: Document, insertions: QuestionnaireInsertion[], @@ -475,31 +551,8 @@ export function insertDecision(target: Document, value: Decision): Mutation | un * on its own. A discrepancy means the projection is stale, never that the * document has decided something different. */ -export function projectAnswer( - target: Document, - id: string, - answers: Record, - settled?: { by: string; at: string }, -): Mutation | undefined { - return mutate(target, () => { - let found = false; - for (let node of $nodesOfType(QuestionnaireNode)) { - if (node.getId() !== id) continue; - found = true; - let value = node.getQuestionnaire(); - node.setQuestionnaire({ - ...value, - // Resolution belongs to the questionnaire, not each answer. - ...(settled ? { by: settled.by, at: settled.at } : {}), - questions: value.questions.map(question => { - let answer = answers[question.id]; - return answer === undefined ? question : { ...question, answer }; - }), - }); - } - return found; - }); -} +export let { projectAnswer, projectCard, hasQuestionnaire, projectOptions, projectPrompt } = + createCardProjections(mutate); /** Append one shared option to a question's projection in the plan. */ export function appendQuestionOption( @@ -568,8 +621,8 @@ export function reconcile( let root = $getRoot(); let all = root.getChildren(); - // An empty paragraph is a caret affordance, not a block the agent can - // address, so it has no place in the mapping and this edit may remove it. + // Empty root paragraphs are shared caret positions. They are not + // addressable source blocks, but must survive reconciliation by identity. let live = all.filter(node => !($isParagraphNode(node) && node.getChildrenSize() === 0)); if (live.length !== before.length) { throw new Error("the plan changed while the edit was being applied"); @@ -588,15 +641,47 @@ export function reconcile( } fresh.forEach((node, index) => nodes.set(node, created[index]!)); - root.splice( - 0, - all.length, - after.map(node => { - let found = nodes.get(node); - if (!found) throw new Error("a plan block has no live node"); - return found; - }), - ); + let ordered = after.map(node => { + let found = nodes.get(node); + if (!found) throw new Error("a plan block has no live node"); + return found; + }); + let surviving = new Set(ordered); + let caretBefore = new Map(); + let groups = new Map(); + let pending: LexicalNode[] = []; + for (let node of all) { + if ($isParagraphNode(node) && node.getChildrenSize() === 0) pending.push(node); + else { + groups.set(node, pending); + pending = []; + } + } + let displaced: LexicalNode[] = []; + let leading: LexicalNode[] = []; + let nextSurvivor: Array = []; + let nearest: LexicalNode | undefined; + for (let index = live.length - 1; index >= 0; index--) { + if (surviving.has(live[index]!)) nearest = live[index]; + nextSurvivor[index] = nearest; + } + for (let [index, node] of live.entries()) { + let carets = groups.get(node) ?? []; + if (!carets.length) continue; + if (index === 0) { + leading.push(...carets); + continue; + } + let recipient = nextSurvivor[index]; + if (recipient) caretBefore.set(recipient, [...caretBefore.get(recipient) ?? [], ...carets]); + else displaced.push(...carets); + } + root.splice(0, all.length, [ + ...leading, + ...ordered.flatMap(node => [...caretBefore.get(node) ?? [], node]), + ...displaced, + ...pending, + ]); return true; }); } @@ -639,6 +724,16 @@ export function hasProse(target: Document): boolean { ); } +export function headingAllowed(target: Document): boolean { + let blocks = parse(project(target)).children; + let placeholder = blocks[0]?.type === "heading" && blocks[0].depth === 1 + && blocks[0].children.length === 0; + return blocks.slice(placeholder ? 1 : 0).every(node => + node.type === "mdxJsxFlowElement" + && (node.name === "Questionnaire" || node.name === "Decision") + ); +} + function anchorForKey(target: Document, key: string | undefined, hash: string): Anchor { let collab = key ? target.binding.collabNodeMap.get(key) : undefined; let type = collab?.getSharedType(); @@ -662,6 +757,18 @@ export function anchorAt(target: Document, index: number, hash: string): Anchor return anchorForKey(target, key, hash); } +/** A questionnaire's index in the canonical block address space. */ +export function questionnaireIndex(target: Document, id: string): number | undefined { + let found: number | undefined; + target.editor.getEditorState().read(() => { + let matches = addressable().flatMap((node, index) => + node instanceof QuestionnaireNode && node.getId() === id ? [index] : [] + ); + if (matches.length === 1) found = matches[0]; + }); + return found; +} + /** * A position at the end of the block at `index`. * @@ -1062,3 +1169,11 @@ export function rebasePassage(target: Document, passage: Passage): Passage { return { ...passage, blocks, drifted: true }; } } + +export function insertResearch(target: Document, id: string): Mutation | undefined { + return mutate(target, () => { + if ($nodesOfType(ResearchNode).some(node => node.getId() === id)) return false; + $getRoot().append($createResearchNode(id)); + return true; + }); +} diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index a6a448a7..1cb402d1 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -12,8 +12,20 @@ import * as Y from "yjs"; import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { ULID } from "@chopin/dialect"; import { MENTION } from "@chopin/protocol/address"; +import * as Question from "@chopin/question"; + +import { validateSource } from "../conversation-plan/sources"; +import { restoreState as restoreConversationPlan } from "../conversation-plan/domain"; +import { assertEventCapacity } from "../conversation-plan/events"; +import { assertOptionCapacity } from "../conversation-plan/option-capacity"; +import { type Effect, restoreEffectOutbox } from "../conversation-plan/effects"; +import * as Jobs from "../conversation-plan/jobs"; +import { restorePendingCardActions } from "../questions/card-actions"; +import { validateNotice, validateScopedNotices } from "./notice-validation"; import * as presence from "./presence"; import * as edit from "./edit"; @@ -27,14 +39,22 @@ import { claimEligibility, restoreLifecycle, transition } from "../tasks/lifecyc import { broadcast, fail, relay, reply, tell } from "../wire"; import type { Server } from "bun"; -import type { Plan as Wire, Request } from "@chopin/protocol"; +import type { ConversationPlan, Plan as Wire, Request } from "@chopin/protocol"; import type { Socket, SocketData } from "../wire"; import type { Presence } from "./presence"; import type { Document } from "./room"; import type { Block } from "./edit"; import type { Brief, CreationOrigin } from "../mcp"; -import type { InitialChannel, JsonValue, Lease, StoredChannel } from "../storage/model"; +import { researchProjectionAllowed, ResearchProjectionConflict } from "../storage/model"; +import type { + InitialChannel, + JsonValue, + Lease, + ResearchProjectionChange, + StoredChannel, +} from "../storage/model"; import type { StorageAdapter } from "../storage/port"; +import type { PendingCardAction } from "../questions/card-actions"; import type { ClaimInput, ClaimResult, Graph, Run } from "../tasks/graphs"; import type { Lifecycle, LifecycleInput, LifecycleResult } from "../tasks/lifecycle"; @@ -139,6 +159,18 @@ export type Plan = { comments: Comments.Threads; /** The conversation driving the agent. */ chat: Chat.Chat; + /** Replayable conversation-derived cards and durable inference queue. */ + conversationPlan: ConversationPlan.State; + /** Durable request IDs for explicit analysis retries. */ + conversationPlanRetries: Array<{ id: string; messageId: string }>; + /** Background Planner work retained across restarts. */ + conversationPlanJobs: ConversationPlan.Job[]; + /** Completed delivery keys and unfinished durable conversation work. */ + conversationPlanEffects: string[]; + conversationPlanPendingEffects: Effect[]; + /** Card actions waiting to be mirrored into their conversation thread. */ + pendingCardActions: PendingCardAction[]; + /** * Every questionnaire this plan has ever held, answered or not. * @@ -207,6 +239,12 @@ type Sidecar = { openQuestions: Questions.StoredOpen[]; threads: Comments.Record[]; transcript: Chat.Chat["entries"]; + conversationPlan?: ConversationPlan.State; + conversationPlanRetries?: Array<{ id: string; messageId: string }>; + conversationPlanJobs?: ConversationPlan.Job[]; + conversationPlanEffects?: string[]; + conversationPlanPendingEffects?: Effect[]; + pendingCardActions?: PendingCardAction[]; }; function state(plan: Plan): Sidecar { @@ -225,6 +263,20 @@ function state(plan: Plan): Sidecar { openQuestions: Questions.dump(plan.questions), threads: [...plan.threads.values()], transcript: plan.chat.entries, + conversationPlan: plan.conversationPlan, + ...(plan.conversationPlanRetries.length + ? { conversationPlanRetries: plan.conversationPlanRetries } + : {}), + ...(plan.conversationPlanJobs.length + ? { conversationPlanJobs: plan.conversationPlanJobs } + : {}), + ...(plan.conversationPlanEffects.length + ? { conversationPlanEffects: plan.conversationPlanEffects } + : {}), + ...(plan.conversationPlanPendingEffects.length + ? { conversationPlanPendingEffects: plan.conversationPlanPendingEffects } + : {}), + ...(plan.pendingCardActions.length ? { pendingCardActions: plan.pendingCardActions } : {}), }; } @@ -234,6 +286,8 @@ function jsonState(plan: Plan): { value: JsonValue; text: string } { } function capture(plan: Plan): Captured { + assertEventCapacity(plan.conversationPlan, plan.pendingCardActions.length); + if (plan.persistence) assertOptionCapacity(plan); let sidecar = jsonState(plan); let source = room.project(plan.document); return { @@ -344,6 +398,16 @@ function legacyCreation( return creation({ brief, origin }); } +function definitionShape(value: Question.Definition) { + return value.questions.map(question => [ + question.id, + question.header, + question.question, + question.multiple, + question.options.map(option => [option.id, option.label, option.description]), + ]); +} + function restoredState( value: JsonValue, pristine: boolean, @@ -385,6 +449,14 @@ function restoredState( if (Object.hasOwn(item, "execution")) expected.push("execution"); if (Object.hasOwn(item, "lifecycle")) expected.push("lifecycle"); if (Object.hasOwn(item, "mcpUpdates")) expected.push("mcpUpdates"); + if (Object.hasOwn(item, "conversationPlan")) expected.push("conversationPlan"); + if (Object.hasOwn(item, "conversationPlanRetries")) expected.push("conversationPlanRetries"); + if (Object.hasOwn(item, "conversationPlanJobs")) expected.push("conversationPlanJobs"); + if (Object.hasOwn(item, "conversationPlanEffects")) expected.push("conversationPlanEffects"); + if (Object.hasOwn(item, "conversationPlanPendingEffects")) { + expected.push("conversationPlanPendingEffects"); + } + if (Object.hasOwn(item, "pendingCardActions")) expected.push("pendingCardActions"); expected.sort(); if ( keys.length !== expected.length @@ -416,18 +488,24 @@ function restoredState( } let lifecycle = hasLifecycle ? restoredLifecycle : undefined; let questions = objects(item.questions, "question record"); - for (let question of questions) { - if ( - (question.status !== "open" && question.status !== "answered" - && question.status !== "cancelled") - || !question.definition - || typeof question.definition !== "object" - || Array.isArray(question.definition) - ) throw new Error("hosted channel has an invalid question record"); - } + let records = questions.map(question => Questions.normalizeRecord(question)); let openQuestions = objects(item.openQuestions, "open questionnaire"); + let recordIds = new Set(records.map(record => record.id)); let openIds = new Set(openQuestions.map(entry => entry.id as string)); - if (questions.some(record => (record.status === "open") !== openIds.has(record.id as string))) { + let definitions = new Map( + records.map(record => [record.id, Question.identified(record.definition)]), + ); + if ( + [...openIds].some(id => !recordIds.has(id)) + || records.some(record => Questions.isOpenStatus(record.status) !== openIds.has(record.id)) + || openQuestions.some(entry => { + let record = definitions.get(entry.id as string); + if (!record) return true; + let draft = Question.identified(entry.definition); + return JSON.stringify(definitionShape(record)) + !== JSON.stringify(definitionShape(draft)); + }) + ) { throw new Error("hosted channel question records disagree with their drafts"); } let threads = objects(item.threads, "comment thread"); @@ -441,6 +519,19 @@ function restoredState( ) throw new Error("hosted channel has an invalid comment thread"); } let transcript = objects(item.transcript, "transcript entry"); + let savedMessages = new Map(transcript.map(entry => [entry.id, entry])); + for (let record of records) { + for (let origin of Object.values(record.optionOrigins)) { + if (!origin.source) continue; + let message = savedMessages.get(origin.source.messageId); + if (!message) throw new Error("hosted channel has option source without a message"); + try { + validateSource(origin.source, message as Chat.Chat["entries"][number]); + } catch (err) { + throw new Error("hosted channel has invalid option source", { cause: err }); + } + } + } let referenceIds = new Set(); for (let entry of transcript) { if ( @@ -450,6 +541,7 @@ function restoredState( || typeof entry.author !== "object" || Array.isArray(entry.author) ) throw new Error("hosted channel has an invalid transcript entry"); + validateNotice(entry, savedMessages); if (Object.hasOwn(entry, "references")) { let author = entry.author as Record; if (author.kind !== "member") { @@ -473,6 +565,52 @@ function restoredState( } } let mcpUpdates = restoreMcpUpdates(item.mcpUpdates); + let conversationPlan = restoreConversationPlan( + item.conversationPlan, + transcript as unknown as Chat.Chat["entries"], + ); + validateScopedNotices(transcript as unknown as Chat.Chat["entries"], conversationPlan, records); + for (let record of records) { + for (let [optionId, origin] of Object.entries(record.optionOrigins)) { + if ( + origin.origin === "chat" && origin.source + && !conversationPlan.events.some(event => + event.type === "option.added" && event.threadId === record.threadId + && event.contribution.id === optionId + && isDeepStrictEqual(event.source, origin.source) + ) + ) { + throw new Error("hosted channel has option source outside its card thread or evidence"); + } + if (origin.source?.role !== "question") continue; + let thread = conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === record.id + ); + let option = record.definition.questions.flatMap(item => item.options) + .find(item => item.id === optionId); + if ( + !Questions.matchesQuestionSource(origin.source, thread) + || !option || !Questions.questionMentionsOption(origin.source.quote, option.label) + ) { + throw new Error( + "hosted channel has option question source outside its card thread or evidence", + ); + } + } + } + let conversationPlanRetries = restoreConversationPlanRetries(item.conversationPlanRetries); + let conversationPlanJobs = Jobs.restore(item.conversationPlanJobs); + let outbox = restoreEffectOutbox( + item.conversationPlanPendingEffects, + item.conversationPlanEffects, + ); + let pendingCardActions = restorePendingCardActions(item.pendingCardActions); + let messageIds = new Set(transcript.map(entry => entry.id as string)); + if ( + conversationPlan.queue.some(entry => !messageIds.has(entry.messageId)) + || conversationPlan.analysis.some(entry => !messageIds.has(entry.messageId)) + || conversationPlanRetries.some(entry => !messageIds.has(entry.messageId)) + ) throw new Error("hosted channel has conversation analysis without a source message"); return { version: 1, revision: item.revision, @@ -482,13 +620,43 @@ function restoredState( ...(execution ? { execution } : {}), ...(lifecycle ? { lifecycle } : {}), ...(mcpUpdates.length > 0 ? { mcpUpdates } : {}), - questions: questions as never[], + questions: records, openQuestions: openQuestions as unknown as Questions.StoredOpen[], threads: threads as never[], transcript: transcript as unknown as Chat.Chat["entries"], + conversationPlan, + ...(conversationPlanRetries.length ? { conversationPlanRetries } : {}), + ...(conversationPlanJobs.length ? { conversationPlanJobs } : {}), + ...(outbox.receipts.length ? { conversationPlanEffects: outbox.receipts } : {}), + ...(outbox.pending.length ? { conversationPlanPendingEffects: outbox.pending } : {}), + ...(pendingCardActions.length ? { pendingCardActions } : {}), }; } +function restoreConversationPlanRetries( + value: JsonValue | undefined, +): Array<{ id: string; messageId: string }> { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 4096) { + throw new Error("hosted channel has invalid conversation analysis retries"); + } + let seen = new Set(); + return value.map(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("hosted channel has invalid conversation analysis retry"); + } + let item = raw as Record; + if ( + Object.keys(item).sort().join(",") !== "id,messageId" + || typeof item.id !== "string" || !item.id || item.id.length > 250 + || typeof item.messageId !== "string" || !item.messageId || item.messageId.length > 200 + || seen.has(item.id) + ) throw new Error("hosted channel has invalid conversation analysis retry"); + seen.add(item.id); + return { id: item.id, messageId: item.messageId }; + }); +} + function restoreMcpUpdates(value: JsonValue | undefined): McpUpdateRecord[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error("hosted channel has invalid MCP updates"); @@ -598,6 +766,8 @@ async function commitHosted( operationId: string, captured: Captured, allowArchived = false, + researchProjections: ResearchProjectionChange[] = [], + notifyDocumentPersisted = true, ): Promise { let durable = plan.persistence; if (!update && captured.sidecarText === durable.lastSidecar) { @@ -616,6 +786,7 @@ async function commitHosted( sidecar: captured.sidecar, events: [], now: new Date(), + ...(researchProjections.length > 0 ? { researchProjections } : {}), ...(allowArchived ? { allowArchived: true } : {}), }); if (!result.repeated) { @@ -645,7 +816,7 @@ async function commitHosted( if (plan.document.epoch === captured.epoch) { plan.document.checkpoint = new Uint8Array(captured.document); } - if (update && sourceChanged && durable.onDocumentPersisted) { + if (update && sourceChanged && notifyDocumentPersisted && durable.onDocumentPersisted) { try { durable.onDocumentPersisted({ channelId: durable.channelId, @@ -659,7 +830,7 @@ async function commitHosted( } scheduleCheckpoint(plan); } catch (err) { - durable.fatal(err); + if (!(err instanceof ResearchProjectionConflict)) durable.fatal(err); throw err; } } @@ -779,6 +950,9 @@ type RestoredHosted = { document: Document; needsInitialCheckpoint: boolean; sidecar: Sidecar; + /** Exact saved sidecar, before interrupted jobs are normalized in memory. */ + persistedSidecar: JsonValue; + interruptedJobs: boolean; }; /** Prepare the sidecar half of one atomic claim for a plan that is not live. */ @@ -854,13 +1028,21 @@ async function restoreHosted(id: string, loaded: StoredChannel): Promise).conversationPlanJobs + : undefined; + let interruptedJobs = Array.isArray(persistedJobs) + && persistedJobs.some(job => (job as { status: string }).status === "running"); if (loaded.snapshot) { if ( loaded.snapshot.revision > loaded.channel.revision @@ -893,7 +1075,7 @@ async function restoreHosted(id: string, loaded: StoredChannel): Promise { let loaded = await backend.storage.collaboration.load(id, new Date()); if (!loaded) throw new Error(`channel ${id} does not exist`); - let { document, needsInitialCheckpoint, sidecar } = await restoreHosted(id, loaded); + let { document, needsInitialCheckpoint, sidecar, persistedSidecar, interruptedJobs } = + await restoreHosted(id, loaded); let plan: Plan = { id, @@ -943,8 +1126,16 @@ export async function open( questions: Questions.restore(sidecar.openQuestions), comments: Comments.create(), chat: Chat.restore(sidecar.transcript), + conversationPlan: sidecar.conversationPlan ?? restoreConversationPlan(undefined), + conversationPlanRetries: sidecar.conversationPlanRetries ?? [], + conversationPlanJobs: sidecar.conversationPlanJobs ?? [], + conversationPlanEffects: sidecar.conversationPlanEffects ?? [], + conversationPlanPendingEffects: sidecar.conversationPlanPendingEffects ?? [], + pendingCardActions: sidecar.pendingCardActions ?? [], outlines: new Map(), - records: new Map(sidecar.questions.map(record => [record.id, record])), + records: new Map( + sidecar.questions.map(record => [record.id, Questions.normalizeRecord(record)]), + ), threads: new Map(sidecar.threads.map(record => [record.id, record])), revision: sidecar.revision, graph: sidecar.graph, @@ -973,7 +1164,20 @@ export async function open( committedSidecar: committed.sidecar, closing: false, }; - if (needsInitialCheckpoint) await checkpointHosted(plan); + if (interruptedJobs) { + plan.persistence.lastSidecar = JSON.stringify(persistedSidecar); + plan.persistence.committedSidecar = persistedSidecar; + } + try { + if (interruptedJobs) await persistExclusive(plan, true); + if (needsInitialCheckpoint) await checkpointHosted(plan); + } catch (error) { + if (plan.persistence.checkpointTimer) clearTimeout(plan.persistence.checkpointTimer); + Questions.shutdown(plan.questions); + presence.destroy(plan.presence); + plan.document.doc.destroy(); + throw error; + } // Guarded because this is the last thing between a channel and being open. A // plan whose highlights are stale is worth having; one that refuses to open @@ -990,7 +1194,22 @@ export async function open( /** Cheap identity of the whole relationship snapshot, for spotting a change. */ function signature(plan: Plan): string { - return JSON.stringify([Questions.anchors(plan), Comments.anchors(plan)]); + return JSON.stringify([Questions.anchors(plan), Comments.anchors(plan), Questions.prose(plan)]); +} + +function proseOrphans(plan: Plan): Map { + return new Map(Questions.prose(plan).map(item => [item.widget, item.orphaned])); +} + +function announceProseChanges(plan: Plan, before: Map): void { + for (let item of Questions.prose(plan)) { + if (before.get(item.widget) === item.orphaned) continue; + try { + Questions.announce(plan, plan.server, plan.id, item.widget); + } catch (err) { + console.error("[plan] could not announce decided prose metadata:", err); + } + } } /** Everything a joining client needs to start from. */ @@ -1007,6 +1226,7 @@ export function greet(plan: Plan, ws: Socket, msg: Request): void revision: plan.revision, anchors: Questions.anchors(plan), threads: Comments.anchors(plan), + prose: Questions.prose(plan), limits: room.LIMITS, ...(hello ? { awareness: encode(hello) } : {}), }); @@ -1056,6 +1276,52 @@ function schedule(plan: Plan): void { }, GROUP_MS); } +async function rejectBatch(plan: Plan, batch: Queued[], issues: string[]): Promise { + console.warn("[plan] rejected batch:", issues.join(", ")); + let proseBefore = proseOrphans(plan); + + let now = Date.now(); + for (let item of batch) { + let gauge = meter(plan, item.ws); + gauge.invalid = [...recent(gauge.invalid, INVALID_WINDOW_MS), now]; + if (gauge.invalid.length >= INVALID_LIMIT) { + item.ws.close(ABUSIVE, "repeated invalid plan updates"); + } + } + + let rebuilt = await room.rebuild(plan.document); + plan.document = rebuilt; + // Cursors describe positions in a history that no longer exists. The + // agent's is in there too, and the interval repeating it would outlive + // the presence it repeats. + clearInterval(plan.attention); + plan.attention = undefined; + presence.destroy(plan.presence); + plan.presence = presence.create(); + // So do anchors and passages, and unlike a cursor nobody re-announces + // them. Without this every highlight in the room stays dark until the + // agent happens to edit. + // + // Guarded because the `plan:reset` below is what tells everyone to + // re-open. A throw here would strand the whole room on an epoch that no + // longer exists, to save some highlights that are already stale. + try { + Questions.rebase(plan); + Comments.rebase(plan); + } catch (err) { + console.error("[plan] could not carry anchors onto the rebuilt document:", err); + } + await replaceHosted(plan, `epoch:${rebuilt.epoch}`, capture(plan)); + + broadcast(plan.server, plan.id, { + kind: "plan:reset", + ts: 0, + epoch: rebuilt.epoch, + reason: "rebuilt", + }); + announceProseChanges(plan, proseBefore); +} + /** * Apply one batch. * @@ -1068,55 +1334,34 @@ async function commit(plan: Plan): Promise { if (batch.length === 0) return; plan.queue = []; - let outcome = await room.apply(plan.document, batch.map(item => item.update)); + let outcome = await room.apply( + plan.document, + batch.map(item => item.update), + async (id, action) => { + if (!ULID.test(id)) return false; + let request = await plan.persistence.storage.research.get(plan.id, id); + let initial = request?.turns.find(turn => turn.kind === "initial"); + let jobId = initial?.answerJobId ?? initial?.evidenceJobId; + let job = jobId ? await plan.persistence.storage.jobs.get(plan.id, jobId) : undefined; + return researchProjectionAllowed( + plan.id, + { id, action }, + request?.workspace, + initial, + job?.job, + ); + }, + ); if (!outcome.ok) { - console.warn("[plan] rejected batch:", outcome.issues.join(", ")); - - let now = Date.now(); - for (let item of batch) { - let gauge = meter(plan, item.ws); - gauge.invalid = [...recent(gauge.invalid, INVALID_WINDOW_MS), now]; - if (gauge.invalid.length >= INVALID_LIMIT) { - item.ws.close(ABUSIVE, "repeated invalid plan updates"); - } - } - - let rebuilt = await room.rebuild(plan.document); - plan.document = rebuilt; - // Cursors describe positions in a history that no longer exists. The - // agent's is in there too, and the interval repeating it would outlive - // the presence it repeats. - clearInterval(plan.attention); - plan.attention = undefined; - presence.destroy(plan.presence); - plan.presence = presence.create(); - // So do anchors and passages, and unlike a cursor nobody re-announces - // them. Without this every highlight in the room stays dark until the - // agent happens to edit. - // - // Guarded because the `plan:reset` below is what tells everyone to - // re-open. A throw here would strand the whole room on an epoch that no - // longer exists, to save some highlights that are already stale. - try { - Questions.rebase(plan); - Comments.rebase(plan); - } catch (err) { - console.error("[plan] could not carry anchors onto the rebuilt document:", err); - } - await replaceHosted(plan, `epoch:${rebuilt.epoch}`, capture(plan)); - - broadcast(plan.server, plan.id, { - kind: "plan:reset", - ts: 0, - epoch: rebuilt.epoch, - reason: "rebuilt", - }); + await rejectBatch(plan, batch, outcome.issues); return; } let relationshipsChanged = false; let before = signature(plan); + let previousRecords = new Map(plan.records); + let previousThreads = new Map(plan.threads); try { Questions.rebase(plan); Comments.rebase(plan); @@ -1124,12 +1369,29 @@ async function commit(plan: Plan): Promise { } catch (err) { console.error("[plan] could not carry anchors forward:", err); } + let previousRevision = plan.revision; if (room.project(plan.document) !== plan.persistence.committedSource) plan.revision++; let merged = Y.mergeUpdates(batch.map(item => item.update)); let operationId = `plan:${plan.document.epoch}:${ createHash("sha256").update(merged).digest("hex") }`; - await commitHosted(plan, merged, operationId, capture(plan)); + try { + await commitHosted( + plan, + merged, + operationId, + capture(plan), + false, + outcome.researchProjections, + ); + } catch (err) { + if (!(err instanceof ResearchProjectionConflict)) throw err; + plan.revision = previousRevision; + plan.records = previousRecords; + plan.threads = previousThreads; + await rejectBatch(plan, batch, ["research-reference-conflict"]); + return; + } for (let item of batch) { reply(item.ws, item.rid, { @@ -1185,6 +1447,70 @@ export async function publish( } } +class ImplementationActiveError extends Error { + constructor() { + super("implementation is active"); + } +} + +/** Commit a staged document and sidecar before bringing the live room forward. */ +export async function publishStaged( + plan: Plan, + server: Server, + roomId: string, + candidate: Plan, + mutation?: room.Mutation, + options?: { notifyDocumentPersisted?: boolean }, +): Promise { + if (implementationActive(plan)) throw new ImplementationActiveError(); + let source = room.project(candidate.document); + if (!mutation && source !== room.project(plan.document)) { + throw new Error("staged document changed without a mutation"); + } + if (mutation) candidate.document.seq++; + if (source !== plan.persistence.committedSource) candidate.revision++; + let operationId = mutation + ? `server:${candidate.document.epoch}:${ + createHash("sha256").update(mutation.update).digest("hex") + }` + : `state:${crypto.randomUUID()}`; + await commitHosted( + plan, + mutation?.update, + operationId, + capture(candidate), + false, + [], + options?.notifyDocumentPersisted !== false, + ); + if (mutation) { + Y.applyUpdate(plan.document.doc, mutation.update); + await room.settle(); + } + plan.document.seq = candidate.document.seq; + plan.revision = candidate.revision; + plan.records = candidate.records; + plan.threads = candidate.threads; + plan.questions = candidate.questions; + plan.conversationPlan = candidate.conversationPlan; + plan.pendingCardActions = candidate.pendingCardActions; + plan.conversationPlanPendingEffects = candidate.conversationPlanPendingEffects; + plan.conversationPlanEffects = candidate.conversationPlanEffects; + if (mutation) { + try { + broadcast(server, roomId, { + kind: "plan:update", + ts: 0, + epoch: plan.document.epoch, + update: encode(mutation.update), + seq: plan.document.seq, + }); + } catch (err) { + console.error("[plan] could not broadcast a persisted update:", err); + } + } +} + export async function rewrite( plan: Plan, nextSource: string, @@ -1242,7 +1568,7 @@ export async function rewrite( plan.mcpUpdates.push(recorded); plan.outlines = candidate.outlines; if (changed) { - Questions.rebase(plan); + Questions.rebase(plan, before); Questions.invalidate(plan, "plan_changed"); Comments.rebase(plan); Comments.invalidate(plan, "plan_changed"); @@ -1356,13 +1682,24 @@ export function anchors( server: Server, roomId: string, ): void { + let prose = Questions.prose(plan); broadcast(server, roomId, { kind: "plan:anchors", ts: 0, epoch: plan.document.epoch, widgets: Questions.anchors(plan), threads: Comments.anchors(plan), + prose, }); + // Other post-commit callers also rebase prose; repeat metadata so none can + // publish a stale collapse state after an edit or rewrite. + for (let item of prose) { + try { + Questions.announce(plan, server, roomId, item.widget); + } catch (err) { + console.error("[plan] could not announce decided prose metadata:", err); + } + } } /** @@ -1476,6 +1813,34 @@ export function departed(plan: Plan, ws: Socket): void { }); } +/** Release a successfully opened document that has never been exposed to a room. */ +export async function abortOpening(plan: Plan): Promise { + if (plan.timer) clearTimeout(plan.timer); + clearInterval(plan.attention); + let persistence = plan.persistence; + persistence.closing = true; + if (persistence.checkpointTimer) clearTimeout(persistence.checkpointTimer); + persistence.checkpointTimer = undefined; + try { + let results = await Promise.allSettled([Chat.close(plan.chat), plan.flushing]); + let failed = results.filter(result => result.status === "rejected"); + if (failed.length === 1) throw failed[0]!.reason; + if (failed.length > 1) { + throw new AggregateError(failed.map(result => result.reason), "document cleanup failed"); + } + } finally { + try { + Questions.shutdown(plan.questions); + } finally { + try { + presence.destroy(plan.presence); + } finally { + plan.document.doc.destroy(); + } + } + } +} + /** Write anything outstanding and let go. */ export async function close(plan: Plan): Promise { if (plan.timer) clearTimeout(plan.timer); @@ -1502,3 +1867,29 @@ export function source(plan: Plan): string { export function size(plan: Plan): number { return Y.encodeStateAsUpdate(plan.document.doc).byteLength; } + +export function placeResearchReference(plan: Plan, id: string): Promise<"placed" | "deferred"> { + return exclusive(plan, async () => { + if (implementationActive(plan)) return "deferred"; + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + try { + let mutation = room.insertResearch(document, id); + if (!mutation) return "placed"; + try { + await publishStaged(plan, plan.server, plan.id, { ...plan, document }, mutation); + } catch (err) { + if (err instanceof ImplementationActiveError) return "deferred"; + throw err; + } + return "placed"; + } finally { + document.doc.destroy(); + } + }); +} diff --git a/apps/server/src/plan/staged-publication-guards.test.ts b/apps/server/src/plan/staged-publication-guards.test.ts new file mode 100644 index 00000000..d380d9f6 --- /dev/null +++ b/apps/server/src/plan/staged-publication-guards.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { openPlan } from "../testing/plan"; +import * as room from "./room"; +import * as Service from "./service"; +import { staged } from "./staged-publication.test-fixtures"; + +test("staged source changes require their document mutation and respect active implementation", async () => { + let { plan, server, broadcasts } = await openPlan("# Before\n"); + let { candidate, mutation } = await staged(plan, "# After\n"); + try { + await expect(Service.publishStaged(plan, server, plan.id, candidate)) + .rejects.toThrow("staged document changed without a mutation"); + plan.claiming = true; + await expect(Service.publishStaged(plan, server, plan.id, candidate, mutation)) + .rejects.toThrow("implementation is active"); + expect(room.project(plan.document)).toBe("# Before\n"); + expect(broadcasts).toEqual([]); + } finally { + plan.claiming = false; + candidate.document.doc.destroy(); + await Service.close(plan); + } +}); + +test("staged notification opt-out preserves the default durable document callback", async () => { + let { plan, server } = await openPlan("# Before\n"); + let observed: string[] = []; + plan.persistence.onDocumentPersisted = event => { + observed.push(event.source); + }; + let first = await staged(plan, "# First\n"); + let second: Awaited> | undefined; + try { + await Service.exclusive( + plan, + () => + Service.publishStaged(plan, server, plan.id, first.candidate, first.mutation, { + notifyDocumentPersisted: false, + }), + ); + expect(observed).toEqual([]); + second = await staged(plan, "# Second\n"); + let next = second; + await Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, next.candidate, next.mutation), + ); + expect(observed).toEqual(["# Second\n"]); + } finally { + first.candidate.document.doc.destroy(); + second?.candidate.document.doc.destroy(); + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/staged-publication.test-fixtures.ts b/apps/server/src/plan/staged-publication.test-fixtures.ts new file mode 100644 index 00000000..35413c9a --- /dev/null +++ b/apps/server/src/plan/staged-publication.test-fixtures.ts @@ -0,0 +1,25 @@ +import * as Y from "yjs"; +import * as edit from "./edit"; +import * as room from "./room"; +import type { Plan } from "./service"; + +export async function staged(plan: Plan, source: string) { + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + let candidate = { + ...plan, + document, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + conversationPlanEffects: [...plan.conversationPlanEffects, "heading:document:m1"], + }; + let result = edit.replace(candidate, plan.revision, source); + if (!result.ok || !result.mutation) throw new Error("test edit must change the document"); + return { candidate, mutation: result.mutation }; +} diff --git a/apps/server/src/plan/staged-publication.test.ts b/apps/server/src/plan/staged-publication.test.ts new file mode 100644 index 00000000..98d8aec1 --- /dev/null +++ b/apps/server/src/plan/staged-publication.test.ts @@ -0,0 +1,73 @@ +import { expect, spyOn, test } from "bun:test"; +import { openPlan } from "../testing/plan"; +import * as room from "./room"; +import * as Service from "./service"; +import { staged } from "./staged-publication.test-fixtures"; + +test("staged document and receipts stay private until their fenced commit succeeds", async () => { + let context = await openPlan("# Before\n"); + let { plan, storage, server, broadcasts } = context; + let { candidate, mutation } = await staged(plan, "# After\n"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commit = storage.collaboration.commit.bind(storage.collaboration); + let pending = spyOn(storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commit(input); + }); + try { + let publishing = Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, candidate, mutation), + ); + await entered.promise; + expect(room.project(plan.document)).toBe("# Before\n"); + expect(plan.conversationPlanEffects).toEqual([]); + expect(broadcasts).toEqual([]); + let saved = await storage.collaboration.load(plan.id, new Date()); + expect(saved?.sidecar).not.toHaveProperty("conversationPlanEffects"); + release.resolve(); + await publishing; + expect(room.project(plan.document)).toBe("# After\n"); + expect(plan.conversationPlanEffects).toEqual(["heading:document:m1"]); + expect(broadcasts.map(frame => frame.kind)).toEqual(["plan:update"]); + await Service.close(plan); + let reopened = await Service.open(plan.id, context.backend, server); + expect(room.project(reopened.document)).toBe("# After\n"); + expect(reopened.conversationPlanEffects).toEqual(["heading:document:m1"]); + await Service.close(reopened); + } finally { + release.resolve(); + pending.mockRestore(); + candidate.document.doc.destroy(); + } +}); + +test("failed staged commit leaves live and reopened document and receipts unchanged", async () => { + let context = await openPlan("# Before\n"); + let { plan, server, storage, broadcasts } = context; + let { candidate, mutation } = await staged(plan, "# After\n"); + let failing = spyOn(storage.collaboration, "commit").mockRejectedValue(new Error("disk full")); + try { + await expect( + Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, candidate, mutation), + ), + ).rejects.toThrow("disk full"); + expect(room.project(plan.document)).toBe("# Before\n"); + expect(plan.revision).toBe(0); + expect(plan.document.seq).toBe(0); + expect(plan.conversationPlanEffects).toEqual([]); + expect(broadcasts).toEqual([]); + } finally { + failing.mockRestore(); + candidate.document.doc.destroy(); + } + await Service.close(plan); + let reopened = await Service.open(plan.id, context.backend, server); + expect(room.project(reopened.document)).toBe("# Before\n"); + expect(reopened.conversationPlanEffects).toEqual([]); + await Service.close(reopened); +}); diff --git a/apps/server/src/question-service-deferral.test-fixtures.ts b/apps/server/src/question-service-deferral.test-fixtures.ts new file mode 100644 index 00000000..816b2971 --- /dev/null +++ b/apps/server/src/question-service-deferral.test-fixtures.ts @@ -0,0 +1,111 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +import type { Plan } from "./plan/service"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export const d02ProposalText = "We should use the VPS for the beta."; + +export const d02DeferralText = + "I take back my VPS preference for now. Let’s verify backups before choosing hosting."; + +export const d02DeferralQuote = "Let’s verify backups before choosing hosting."; + +export function d02Source( + messageId: string, + handle: string, + text: string, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + let start = text === d02DeferralQuote ? d02DeferralText.indexOf(text) : 0; + return { + messageId, + author: { kind: "member", handle }, + quote: text, + start, + end: start + text.length, + role, + }; +} + +export function d02Settlement(plan: Plan, cardId: string) { + let record = plan.records.get(cardId); + let threadId = record?.threadId; + let thread = plan.conversationPlan.threads.find(item => item.id === threadId); + let optionId = record?.definition.questions[0]?.options[0]?.id; + if (!record || !threadId || !thread || !optionId) { + throw new Error("D02 settlement test needs a linked open card"); + } + let proposal = { + id: "d02:settle-m5", + type: "settle.suggested", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: d02Source("m5", "Bram", d02ProposalText, "resolution"), + optionId, + } as unknown as ConversationPlan.Event; + let deferral = { + id: "d02:settle-deferred-m7", + type: "settle.deferred", + threadId, + observedThreadVersion: thread.version + 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 7, + proposalId: proposal.id, + source: d02Source("m7", "Bram", d02DeferralQuote, "constraint"), + } as unknown as ConversationPlan.Event; + let pendingSettle = { optionId, proposer: "Bram", messageId: "m5" }; + let transcript: Chat.Entry[] = [ + { + id: "m5", + author: { kind: "member", handle: "Bram" }, + text: d02ProposalText, + ts: 5, + }, + { + id: "m7", + author: { kind: "member", handle: "Bram" }, + text: d02DeferralText, + ts: 7, + }, + { + id: "d02:save-prompt", + author: { kind: "system" }, + text: "Ready to decide: Where should the beta be hosted?", + ts: 8, + decision: { + questionnaireId: cardId, + kind: "prompt", + generation: record.history.length, + sourceMessageIds: ["m5"], + suggestedOptionId: optionId, + }, + }, + ]; + plan.chat.entries.push(...transcript); + plan.conversationPlan = { + ...plan.conversationPlan, + events: [...plan.conversationPlan.events, proposal], + threads: plan.conversationPlan.threads.map(item => + item.id === threadId + ? { ...item, version: thread.version + 1, pendingSettle } + : item + ), + }; + return { proposal, deferral, transcript, optionId, threadId }; +} + +export function activateD02Deferral(plan: Plan, deferral: ConversationPlan.Event): void { + plan.conversationPlan = { + ...plan.conversationPlan, + events: [...plan.conversationPlan.events, deferral], + threads: plan.conversationPlan.threads.map(thread => + thread.id === deferral.threadId + ? { ...thread, version: thread.version + 1 } + : thread + ), + }; +} diff --git a/apps/server/src/question-service.test-fixtures.ts b/apps/server/src/question-service.test-fixtures.ts new file mode 100644 index 00000000..6d10ff04 --- /dev/null +++ b/apps/server/src/question-service.test-fixtures.ts @@ -0,0 +1,120 @@ +import { afterEach } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; + +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { Socket, SocketData } from "./wire"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export function createQuestionServiceFixture( + readPlans: () => Plan[], + writePlans: (value: Plan[]) => void, +) { + let plans: Plan[] = []; + + async function opened(source = ""): Promise { + let { plan } = await openPlan(source); + plans.push(plan); + return plan; + } + + async function restart(context: Awaited>): Promise { + await Service.close(context.plan); + plans.splice(plans.indexOf(context.plan), 1); + let plan = await Service.open(context.channel.id, context.backend, context.server); + plans.push(plan); + return plan; + } + + function definition(count = 1) { + return Questions.identify({ + questions: Array.from({ length: count }, (_, index) => ({ + header: `Decision ${index + 1}`, + question: `What should decision ${index + 1} be?`, + multiple: false, + options: [{ label: "Choose this", description: "The selected option." }], + })), + }); + } + + function asking( + plan: Plan, + server: Server, + value: ReturnType, + placement?: Parameters[4], + ) { + let created = Promise.withResolvers(); + let waiting = Questions.ask(plan, server, "test", value, placement, created.resolve); + return { created: created.promise, waiting }; + } + + async function answer(plan: Plan): Promise { + for (let record of [...plan.records.values()].toReversed()) { + let item = record.definition.questions[0]!; + let opened = Store.snapshot(plan.questions, record.id); + if (!opened.open) throw new Error("question was not open"); + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "mode"]).set("choices"); + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("answer produced no patch"); + let edited = Store.edit(plan.questions, record.id, [...patch.toBinary()]); + if (!edited.open || !edited.accepted) throw new Error("could not save answer"); + let claimed = Store.claimSubmit(plan.questions, record.id, edited.revision, item.header); + if (!claimed.ok) throw new Error("could not settle question"); + Store.commit(plan.questions, claimed.claim); + } + } + + async function selectFirstOption(plan: Plan, ws: Socket, id: string): Promise { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let question = opened.definition.questions[0]!; + let optionId = question.options[0]!.id; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([question.id, "choice"]).set(optionId); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "d02-select", + id, + patch: [...patch.toBinary()], + }); + return optionId; + } + + function member(handle = "ana") { + let frames: Array> = []; + let ws = { + data: { handle, client: `client-${handle}`, room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; + } + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { plans, opened, restart, definition, asking, answer, selectFirstOption, member }; +} diff --git a/apps/server/src/questions-service-durability-1.test.ts b/apps/server/src/questions-service-durability-1.test.ts new file mode 100644 index 00000000..57ee250d --- /dev/null +++ b/apps/server/src/questions-service-durability-1.test.ts @@ -0,0 +1,174 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("discarding an open card commits its hidden node before releasing the Planner", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let ana = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let before = context.broadcasts.length; + + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + off(); + + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(ana.frames.at(-1)).toMatchObject({ kind: "question:discard", ok: true, resolver: "ana" }); + expect(plan.records.get(id)).toMatchObject({ status: "discarded", resolver: "ana" }); + expect(room.project(plan.document)).toContain( + `thread="${plan.records.get(id)?.threadId}" status="discarded"`, + ); + expect(Store.snapshot(plan.questions, id).open).toBe(false); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "question:resolved", + "question:meta", + ]); + expect(events).toEqual([{ + kind: "discarded", + id, + threadId: plan.records.get(id)?.threadId, + actor: "ana", + }]); + let reopened = await restart(context); + expect(reopened.records.get(id)).toMatchObject({ status: "discarded", resolver: "ana" }); + expect(room.project(reopened.document)).toContain( + `thread="${reopened.records.get(id)?.threadId}" status="discarded"`, + ); + expect(Questions.meta(reopened, reopened.records.get(id)!)).toMatchObject({ + status: "discarded", + resolver: "ana", + }); +}); + +test("discarding a decided card retains its answer, prose, and original decider", async () => { + let context = await openPlan("Decision prose.\n"); + let plan = context.plan; + plans.push(plan); + let digest = room.digests(plan.document)[0]!; + let asked = asking(plan, context.server, definition(), { + revision: plan.revision, + blocks: [[{ index: 0, digest }]], + }); + await asked.created; + let id = [...plan.records.keys()][0]!; + let question = plan.records.get(id)!.definition.questions[0]!; + let option = question.options[0]!.id; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let ana = member("ana"); + await selectFirstOption(plan, ana.ws, id); + await Questions.submit(plan, context.server, "test", ana.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(plan.questions, id)!.revision, + }); + await asked.waiting; + let answered = plan.records.get(id)!; + let prose = room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!); + let history = [{ choices: [option], owner: "earlier", at: 1 }]; + plan.records.set(id, { ...answered, prose: [prose], history }); + let source = room.project(plan.document); + let ben = member("ben"); + + await Questions.discard(plan, context.server, "test", ben.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + + let after = room.project(plan.document); + expect(ben.frames.at(-1)).toMatchObject({ ok: true, resolver: "ben" }); + expect(after).toBe(source.replace('status="decided"', 'status="discarded"')); + expect(after).toContain("Decision prose."); + expect(after).toContain(` { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let ana = member(); + let before = context.broadcasts.length; + let request = (rid: string, target = id) => + Questions.discard( + plan, + context.server, + "test", + ana.ws, + { kind: "question:discard", ts: 0, rid, id: target }, + ); + await request("first"); + await asked.waiting; + let after = context.broadcasts.length; + await request("second"); + await request("unknown", "missing"); + + expect(ana.frames.find(frame => frame.rid === "second")).toMatchObject({ + ok: false, + reason: "resolved", + status: "discarded", + resolver: "ana", + }); + expect(ana.frames.find(frame => frame.rid === "unknown")).toMatchObject({ + ok: false, + reason: "resolved", + status: "discarded", + resolver: "system", + }); + expect(after).toBeGreaterThan(before); + expect(context.broadcasts).toHaveLength(after); +}); diff --git a/apps/server/src/questions-service-durability-2.test.ts b/apps/server/src/questions-service-durability-2.test.ts new file mode 100644 index 00000000..44875166 --- /dev/null +++ b/apps/server/src/questions-service-durability-2.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("a rejected discard commit keeps the selected draft, record, revision, and waiter", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let open = Store.snapshot(plan.questions, id); + if (!open.open) throw new Error("question was not open"); + let question = open.definition.questions[0]!; + let option = question.options[0]!.id; + let ana = member(); + await selectFirstOption(plan, ana.ws, id); + let source = room.project(plan.document); + let revision = plan.revision; + let draftRevision = Store.get(plan.questions, id)!.revision; + let storedBefore = await context.storage.collaboration.load(context.channel.id, context.now); + let broadcasts = context.broadcasts.length; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let settled = false; + void asked.waiting.then(() => settled = true); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + entered.resolve(); + await release.promise; + throw new Error("storage unavailable"); + }; + try { + let pending = Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "failed", + id, + }); + await entered.promise; + expect(ana.frames.find(frame => frame.rid === "failed")).toBeUndefined(); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + release.resolve(); + await pending; + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + off(); + } + + expect(ana.frames.find(frame => frame.rid === "failed")).toMatchObject({ + kind: "question:discard", + ok: false, + reason: "resolving", + }); + expect(plan.revision).toBe(revision); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(Question.read(Store.get(plan.questions, id)!.model, open.definition)[question.id]!.choice) + .toBe(option); + expect(settled).toBe(false); + expect(events).toEqual([]); + expect(context.broadcasts).toHaveLength(broadcasts); + let storedAfter = await context.storage.collaboration.load(context.channel.id, context.now); + expect(storedAfter).toEqual(storedBefore); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "retry", + id, + }); + expect(ana.frames.find(frame => frame.rid === "retry")).toMatchObject({ ok: true }); + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); +}); + +test("a queued discard reads the decision committed ahead of it", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let open = Store.snapshot(plan.questions, id); + if (!open.open) throw new Error("question was not open"); + let question = open.definition.questions[0]!; + let option = question.options[0]!.id; + let ana = member("ana"); + let ben = member("ben"); + await selectFirstOption(plan, ana.ws, id); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let submitting = Questions.submit(plan, context.server, "test", ana.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(plan.questions, id)!.revision, + }); + let discarding = Questions.discard(plan, context.server, "test", ben.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + try { + expect(ben.frames.find(frame => frame.rid === "discard")).toBeUndefined(); + } finally { + release.resolve(); + await Promise.all([blocker, submitting, discarding]); + off(); + } + + expect(await asked.waiting).toMatchObject([{ status: "answered", resolver: "ana" }]); + expect(ana.frames.find(frame => frame.rid === "save")).toMatchObject({ ok: true }); + expect(ben.frames.find(frame => frame.rid === "discard")).toMatchObject({ ok: true }); + expect(plan.records.get(id)).toMatchObject({ + status: "discarded", + owner: "ana", + resolver: "ben", + }); + expect(room.project(plan.document)).toContain(` frame.kind === "question:resolved")).toHaveLength(1); + expect(events.map(event => event.kind)).toEqual(["decided", "discarded"]); +}); diff --git a/apps/server/src/questions-service-durability-3.test.ts b/apps/server/src/questions-service-durability-3.test.ts new file mode 100644 index 00000000..11dcb034 --- /dev/null +++ b/apps/server/src/questions-service-durability-3.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan, storedQuestion } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking, member } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("an implementation started ahead of a queued discard leaves the card open", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let source = room.project(plan.document); + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let ana = member(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + plan.execution = { id: "run-1" } as never; + }); + await entered.promise; + let discarding = Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "blocked", + id, + }); + try { + expect(ana.frames.find(frame => frame.rid === "blocked")).toBeUndefined(); + } finally { + release.resolve(); + await Promise.all([blocker, discarding]); + } + + expect(ana.frames.find(frame => frame.rid === "blocked")).toMatchObject({ + kind: "session:error", + message: "implementation is active", + }); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.snapshot(plan.questions, id).open).toBe(true); + expect(plan.revision).toBe(revision); + expect(context.broadcasts).toHaveLength(broadcasts); + plan.execution = undefined; + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "cleanup", + id, + }); + await asked.waiting; +}); + +test("a reopened card can be discarded while its draft is live", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + plan.records.set(id, { ...plan.records.get(id)!, status: "reopened" }); + let mutation = room.projectCard(plan.document, id, { status: "reopened" }); + if (!mutation) throw new Error("card projection did not change"); + await Service.publish(plan, context.server, "test", mutation); + }); + let ana = member(); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + + expect(ana.frames.at(-1)).toMatchObject({ ok: true, resolver: "ana" }); + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(plan.records.get(id)?.status).toBe("discarded"); + expect(room.project(plan.document)).toContain('status="discarded"'); +}); + +test("discard refuses a missing document projection without changing the record or draft", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + let mutation = room.removeQuestionnaire(plan.document, id); + if (!mutation) throw new Error("card was not in the document"); + await Service.publish(plan, context.server, "test", mutation); + }); + let record = plan.records.get(id)!; + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let ana = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "missing", + id, + }); + off(); + + expect(ana.frames.at(-1)).toMatchObject({ + kind: "question:discard", + ok: false, + reason: "resolving", + }); + expect(plan.records.get(id)).toBe(record); + expect(Store.snapshot(plan.questions, id).open).toBe(true); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(plan.revision).toBe(revision); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(events).toEqual([]); + void asked.waiting; +}); + +test("legacy question records gain card defaults on restore", async () => { + let value = definition(); + let { plan } = await openPlan("", { + questions: [{ + id: "w1", + definition: value, + status: "answered", + answers: {}, + resolver: "ana", + at: 1, + }], + }); + plans.push(plan); + expect(plan.records.get("w1")).toMatchObject({ + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + owner: "ana", + decidedAt: 1, + }); +}); + +test("restore rejects orphaned or duplicate open drafts and records", async () => { + let value = definition(); + let single = Question.decision(value); + let record = { id: "w1", definition: value, status: "open" }; + let draft = { + id: "w1", + definition: single, + widget: "w1", + model: storedQuestion(single), + revision: 0, + }; + await expect(openPlan("", { questions: [], openQuestions: [draft] })).rejects.toThrow( + "question records disagree with their drafts", + ); + await expect(openPlan("", { questions: [record, record], openQuestions: [draft] })) + .rejects.toThrow("invalid or duplicate question record id"); + await expect(openPlan("", { questions: [record], openQuestions: [draft, draft] })) + .rejects.toThrow("invalid or duplicate open questionnaire id"); +}); + +test("a reopened record restores only with its open draft", async () => { + let value = definition(); + let single = Question.decision(value); + let record = { + id: "w1", + definition: value, + status: "reopened", + origin: "conversation", + history: [{ choices: [value.questions[0]!.options[0]!.id], owner: "ana", at: 1 }], + optionOrigins: {}, + editors: [], + }; + let { plan } = await openPlan("", { + questions: [record], + openQuestions: [{ + id: "w1", + definition: single, + widget: "w1", + model: storedQuestion(single), + revision: 0, + }], + }); + plans.push(plan); + expect(plan.records.get("w1")?.status).toBe("reopened"); + expect(Store.get(plan.questions, "w1")).toBeDefined(); + await expect(openPlan("", { questions: [record] })).rejects.toThrow( + "question records disagree with their drafts", + ); +}); diff --git a/apps/server/src/questions-service-durability-4.test.ts b/apps/server/src/questions-service-durability-4.test.ts new file mode 100644 index 00000000..c4cfac8b --- /dev/null +++ b/apps/server/src/questions-service-durability-4.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { SocketData } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { opened, definition, asking } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("present malformed card provenance is refused on restore", async () => { + let base = { + id: "w1", + definition: definition(), + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }; + let invalid: Array<[string, object]> = [ + ["unknown status", { status: "maybe" }], + ["origin", { origin: "unknown" }], + ["null origin", { origin: null }], + ["history", { history: null }], + ["history entry", { history: [{ choices: [], owner: "ana" }] }], + ["option origins", { optionOrigins: [] }], + ["option origin entry", { + optionOrigins: { + [base.definition.questions[0]!.options[0]!.id]: { + origin: "unknown", + }, + }, + }], + ["option origin rationale", { + optionOrigins: { + [base.definition.questions[0]!.options[0]!.id]: { + origin: "planner", + rationale: [], + }, + }, + }], + ["editors", { editors: ["ana", null] }], + ]; + for (let [, fields] of invalid) { + await expect(openPlan("", { questions: [{ ...base, ...fields }] })).rejects.toThrow( + "invalid question record", + ); + } +}); + +test("a card record restores its chosen IDs and provenance", async () => { + let value = definition(); + let option = value.questions[0]!.options[0]!.id; + let threadId = "t".repeat(200); + let { plan } = await openPlan("", { + questions: [{ + id: "w1", + definition: value, + status: "answered", + origin: "conversation", + threadId, + owner: "ben", + decidedAt: 2, + choices: [option], + history: [{ choices: [option], owner: "ana", at: 1 }], + optionOrigins: { [option]: { origin: "human", by: "ana" } }, + editors: ["ana", "ben"], + }], + }); + plans.push(plan); + expect(plan.records.get("w1")).toMatchObject({ + origin: "conversation", + threadId, + owner: "ben", + decidedAt: 2, + choices: [option], + history: [{ choices: [option], owner: "ana", at: 1 }], + optionOrigins: { [option]: { origin: "human", by: "ana" } }, + editors: ["ana", "ben"], + }); +}); + +test("stored card choices and metadata reject unknown, duplicate or malformed values", async () => { + let value = definition(); + let option = value.questions[0]!.options[0]!.id; + let base = { + id: "w1", + definition: value, + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }; + let invalid: Array<[string, object]> = [ + ["malformed definition cardinality", { + definition: { + questions: [{ ...value.questions[0]!, multiple: "no" }], + }, + }], + ["unknown chosen option", { choices: ["missing"] }], + ["duplicate or excessive chosen options", { choices: [option, option] }], + ["malformed choices", { choices: null }], + ["unknown historical option", { history: [{ choices: ["missing"], owner: "ana", at: 1 }] }], + ["duplicate historical choices", { + history: [{ choices: [option, option], owner: "ana", at: 1 }], + }], + ["unknown option origin", { optionOrigins: { missing: { origin: "chat" } } }], + ["duplicate editors", { editors: ["ana", "ana"] }], + ["milliseconds, not Unix seconds", { decidedAt: 1_790_334_980_000 }], + ["nonfinite history timestamp", { history: [{ choices: [], owner: "ana", at: Infinity }] }], + ["invalid owner", { owner: null }], + ["invalid thread", { threadId: [] }], + ["thread too long for a card", { threadId: "x".repeat(201) }], + ["invalid prose anchor", { prose: [{ epoch: "e", position: "p", digest: "bad" }] }], + ["malformed prose position", { + prose: [{ epoch: "e", position: "p", digest: `sha256:${"0".repeat(64)}` }], + }], + ]; + for (let [, fields] of invalid) { + await expect(openPlan("", { questions: [{ ...base, ...fields }] })).rejects.toThrow( + "invalid question record", + ); + } +}); + +test("a batched ask creates independently addressed decision records and nodes", async () => { + let plan = await opened(); + let definition = Questions.identify({ + questions: [ + { + header: "Storage", + question: "Where should room state live?", + multiple: false, + options: [{ label: "MDX on disk", description: "Readable." }], + }, + { + header: "Scope", + question: "What belongs in the first cut?", + multiple: true, + options: [{ label: "Anchors", description: "Link prose." }], + }, + ], + }); + + let server = { publish() {} } as unknown as Server; + let asked = asking(plan, server, definition); + await asked.created; + let records = [...plan.records.values()]; + + expect(records).toHaveLength(2); + expect(records.map(record => ({ + origin: record.origin, + history: record.history, + optionOrigins: record.optionOrigins, + editors: record.editors, + }))).toEqual([ + { origin: "planner", history: [], optionOrigins: {}, editors: [] }, + { origin: "planner", history: [], optionOrigins: {}, editors: [] }, + ]); + expect(records.map(record => record.definition.questions)).toEqual([ + [definition.questions[0]], + [definition.questions[1]], + ]); + expect(room.project(plan.document).match(/ plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("submitting a chosen option projects its id and preserves it after restart", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let record = [...plan.records.values()][0]!; + let question = record.definition.questions[0]!; + let { ws, frames } = member(); + await selectFirstOption(plan, ws, record.id); + let revision = Store.get(plan.questions, record.id)!.revision; + await Questions.submit(plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id: record.id, + revision, + }); + await asked.waiting; + + let projected = ` frame.rid === "save")).toMatchObject({ ok: true }); + expect(room.project(plan.document)).toContain(projected); + let reopened = await restart(context); + expect(room.project(reopened.document)).toContain(projected); +}); + +test("a current-revision manual Save is refused before the deferred draft is claimed", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let actor = member("Bram"); + let optionId = await selectFirstOption(plan, actor.ws, id); + let seed = d02Settlement(plan, id); + activateD02Deferral(plan, seed.deferral); + await Service.persist(plan); + let source = room.project(plan.document); + let events = structuredClone(plan.conversationPlan.events); + let transcript = structuredClone(plan.chat.entries); + let storedBefore = await context.storage.collaboration.load(context.channel.id, context.now); + let revision = Store.get(plan.questions, id)!.revision; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let completed = false; + let submitting = Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "deferred-manual-save", + id, + revision, + }).then(() => completed = true); + try { + await Bun.sleep(20); + expect(completed).toBe(true); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(actor.frames.find(frame => frame.rid === "deferred-manual-save")).toMatchObject({ + kind: "question:submit", + ok: false, + reason: "invalid", + message: expect.stringMatching(/paused pending verification/i), + }); + } finally { + release.resolve(); + await Promise.all([blocker, submitting]); + } + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + plan.records.get(id)!.definition, + )[plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBe(optionId); + expect(room.project(plan.document)).toBe(source); + expect(plan.conversationPlan.events).toEqual(events); + expect(plan.chat.entries).toEqual(transcript); + expect(await context.storage.collaboration.load(context.channel.id, context.now)).toEqual( + storedBefore, + ); +}); + +test("a deferral committed ahead of a claimed submit restores the selected draft", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let actor = member("Bram"); + let optionId = await selectFirstOption(plan, actor.ws, id); + let seed = d02Settlement(plan, id); + await Service.persist(plan); + let source = room.project(plan.document); + let transcript = structuredClone(plan.chat.entries); + let revision = Store.get(plan.questions, id)!.revision; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let deferring = Service.exclusive(plan, async () => { + activateD02Deferral(plan, seed.deferral); + await Service.persistExclusive(plan); + }); + let submitting = Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "racing-save", + id, + revision, + }); + let eventCount = plan.conversationPlan.events.length; + try { + await Bun.sleep(20); + expect(Store.get(plan.questions, id)?.claim).toBe("submit"); + } finally { + release.resolve(); + await Promise.all([blocker, deferring, submitting]); + } + let reply = actor.frames.find(frame => frame.rid === "racing-save"); + expect(reply).toMatchObject({ + kind: "question:submit", + ok: false, + reason: "invalid", + message: expect.stringMatching(/paused pending verification/i), + }); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + plan.records.get(id)!.definition, + )[plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBe(optionId); + expect(room.project(plan.document)).toBe(source); + expect(plan.conversationPlan.events).toHaveLength(eventCount + 1); + expect(plan.conversationPlan.events.at(-1)).toMatchObject({ + id: seed.deferral.id, + proposalId: seed.proposal.id, + source: expect.objectContaining({ + messageId: "m7", + quote: d02DeferralQuote, + role: "constraint", + }), + }); + expect(plan.chat.entries).toEqual(transcript); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + expect(stored?.sidecar).toMatchObject({ + conversationPlan: { + events: expect.arrayContaining([ + expect.objectContaining({ + id: seed.deferral.id, + proposalId: seed.proposal.id, + }), + ]), + }, + }); +}); diff --git a/apps/server/src/questions-service-durability-6.test.ts b/apps/server/src/questions-service-durability-6.test.ts new file mode 100644 index 00000000..b7c00f49 --- /dev/null +++ b/apps/server/src/questions-service-durability-6.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./questions/service"; + +import * as room from "./plan/room"; + +import { replay } from "./conversation-plan/domain"; +import { buildTargetingRequest } from "./conversation-plan/questions"; +import { extractQuotes } from "./conversation-plan/quotes"; +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { Socket, SocketData } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { opened, restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("Planner ask links each card to a durable, targetable thread with its original options", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let value = definition(2); + let asked = asking(plan, context.server, value); + await asked.created; + + let records = [...plan.records.values()]; + expect(records).toHaveLength(2); + expect(plan.conversationPlan.threads).toHaveLength(2); + for (let [index, record] of records.entries()) { + let question = value.questions[index]!; + let thread = plan.conversationPlan.threads[index]!; + expect(record.threadId).toBe(thread.id); + expect(thread).toMatchObject({ + question: question.question, + questionSources: [], + questionAuthoring: "scribe", + questionnaireId: record.id, + status: "exploring", + }); + expect(thread.contributions.map(option => ({ + id: option.id, + text: option.text, + sources: option.sources, + }))).toEqual(question.options.map(option => ({ + id: option.id, + text: option.label, + sources: [], + }))); + expect(room.project(plan.document)).toContain(`thread="${thread.id}"`); + } + let quoted = value.questions[0]!.options[0]!.label; + let message = { + id: "exact-option-quote", + author: { kind: "member" as const, handle: "ana" }, + text: `I support ${quoted}.`, + ts: 1, + }; + let request = buildTargetingRequest( + message, + [], + plan.conversationPlan.threads, + extractQuotes(message.text), + ); + let criteria = (request.questions.c0_option as { criteria: Record }).criteria; + expect(criteria[value.questions[0]!.options[0]!.id]).toContain(quoted); + expect( + (request.questions.c0_thread as { criteria: Record }) + .criteria[records[0]!.threadId!], + ).toContain(value.questions[0]!.question); + + let reopened = await restart(context); + expect( + [...reopened.records.values()].map(record => ({ id: record.id, threadId: record.threadId })), + ) + .toEqual(records.map(record => ({ id: record.id, threadId: record.threadId }))); + expect(reopened.conversationPlan).toEqual(plan.conversationPlan); + expect(replay(reopened.conversationPlan.events).threads).toEqual( + reopened.conversationPlan.threads, + ); + expect(room.project(reopened.document)).toEqual(room.project(plan.document)); +}); + +test("an active implementation leaves an open questionnaire in the plan when discarding is refused", async () => { + let plan = await opened(); + let server = { publish() {} } as unknown as Server; + let asked = asking(plan, server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let replies: Array<{ kind: string; message?: string; rid?: string; ts?: number }> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + } as unknown as Socket; + plan.execution = { id: "run-1" } as never; + + await Questions.discard(plan, server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "cancel", + id, + }); + + expect(replies).toEqual([{ + kind: "session:error", + message: "implementation is active", + ts: expect.any(Number), + rid: "cancel", + }]); + expect(plan.records.get(id)?.status).toBe("open"); + expect(room.project(plan.document)).toContain(" { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let record = [...plan.records.values()][0]!; + plan.records.set(record.id, { ...record, status: "reopened" }); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(record.id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "grow", + id: record.id, + label: "GitHub Apps", + }); + expect(frames.find(frame => frame.kind === "question:option")).toMatchObject({ + ok: true, + id: record.id, + }); + expect(plan.records.get(record.id)?.status).toBe("reopened"); + await Questions.discard(plan, context.server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "cleanup", + id: record.id, + }); + await asked.waiting; +}); diff --git a/apps/server/src/questions-service-durability-7.test.ts b/apps/server/src/questions-service-durability-7.test.ts new file mode 100644 index 00000000..9c447685 --- /dev/null +++ b/apps/server/src/questions-service-durability-7.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("adding an option changes the record, document, and durable state before publication", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish(_topic: string, raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let original = context.storage.collaboration.commit; + let release = Promise.withResolvers(); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await release.promise; + return original(input); + }; + let oldSource = room.project(plan.document); + let oldRevision = plan.revision; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let pending = Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "add", + id, + label: "GitHub Apps", + }); + await Bun.sleep(20); + let editing = Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); + await Bun.sleep(10); + try { + expect(frames).toHaveLength(0); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 0, + ); + expect(room.project(plan.document)).toBe(oldSource); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.revision).toBe(oldRevision); + } finally { + release.resolve(); + } + await Promise.all([pending, editing]); + (context.storage.collaboration as { commit: typeof original }).commit = original; + + let response = frames.find(frame => frame.kind === "question:option"); + let changed = context.broadcasts.find(frame => frame.kind === "question:option-added"); + let update = context.broadcasts.findLast(frame => frame.kind === "plan:update"); + let options = Store.get(plan.questions, id)!.definition.questions[0].options; + expect(response).toMatchObject({ ok: true, option: { label: "GitHub Apps" } }); + expect(frames.find(frame => frame.rid === "select")).toMatchObject({ + open: true, + accepted: true, + applied: true, + revision: 1, + }); + expect(changed).toMatchObject({ id, option: { label: "GitHub Apps" } }); + expect(update).toBeDefined(); + expect(options.map(option => option.label)).toEqual(["Choose this", "GitHub Apps"]); + expect(plan.records.get(id)?.definition.questions[0].options).toEqual(options); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + let liveSource = room.project(plan.document); + expect(liveSource).toContain('label="GitHub Apps"'); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toEqual(options); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + expect(room.project(reopened.document)).toBe(liveSource); +}); + +test("two competing adds with the same label commit one option", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + await Promise.all([ + Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-2", + ts: 0, + rid: "first", + id, + label: "GitHub Apps", + }), + Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-3", + ts: 0, + rid: "second", + id, + label: "github apps", + }), + ]); + + let options = Store.get(plan.questions, id)!.definition.questions[0].options; + expect(frames.find(frame => frame.rid === "first")).toMatchObject({ ok: true }); + expect(frames.find(frame => frame.rid === "second")).toMatchObject({ + ok: false, + reason: "duplicate", + }); + expect(options.map(option => option.label)).toEqual(["Choose this", "GitHub Apps"]); + expect(Store.get(plan.questions, id)?.revision).toBe(0); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 1, + ); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toEqual(options); +}); diff --git a/apps/server/src/questions-service-durability-8.test.ts b/apps/server/src/questions-service-durability-8.test.ts new file mode 100644 index 00000000..daed732a --- /dev/null +++ b/apps/server/src/questions-service-durability-8.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("a failed option commit leaves the live and durable card at their prior shape", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish(_topic: string, raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let source = room.project(plan.document); + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let original = context.storage.collaboration.commit; + let release = Promise.withResolvers(); + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + if (++commits === 1) { + await release.promise; + throw new Error("storage unavailable"); + } + return original(input); + }; + try { + let adding = Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "failed", + id, + label: "GitHub Apps", + }); + await Bun.sleep(20); + let editing = Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select-after-failure", + id, + patch: [...patch.toBinary()], + }); + await Bun.sleep(10); + expect(frames).toHaveLength(0); + release.resolve(); + await Promise.all([adding, editing]); + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + + expect(frames.find(frame => frame.rid === "failed")).toMatchObject({ kind: "session:error" }); + expect(frames.find(frame => frame.rid === "select-after-failure")).toMatchObject({ + open: true, + accepted: true, + applied: true, + }); + expect(context.broadcasts.slice(broadcasts).map(frame => frame.kind)).toEqual(["question:meta"]); + expect(plan.revision).toBe(revision); + expect(room.project(plan.document)).toBe(source); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + let reopened = await restart(context); + expect(room.project(reopened.document)).toBe(source); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + await Questions.addOption(reopened, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "retry", + id, + label: "GitHub Apps", + }); + expect(frames.find(frame => frame.rid === "retry")).toMatchObject({ ok: true }); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(2); +}); + +test("a failed draft commit leaves the selection unacknowledged and unchanged", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "edit-failed", + id, + patch: [...patch.toBinary()], + }); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + + expect(frames).toMatchObject([{ kind: "session:error", rid: "edit-failed" }]); + expect(Store.get(plan.questions, id)?.revision).toBe(0); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBeNull(); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.revision).toBe(0); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBeNull(); +}); + +test("adding to a card removed from the document leaves its record and draft unchanged", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + let mutation = room.removeQuestionnaire(plan.document, id); + if (!mutation) throw new Error("card was not in the document"); + await Service.publish(plan, context.server, "test", mutation); + }); + let source = room.project(plan.document); + let revision = Store.get(plan.questions, id)!.revision; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "missing", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ kind: "session:error", rid: "missing" }]); + expect(Store.get(plan.questions, id)?.revision).toBe(revision); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(plan.document)).toBe(source); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(reopened.document)).toBe(source); +}); diff --git a/apps/server/src/questions-service-durability-9.test.ts b/apps/server/src/questions-service-durability-9.test.ts new file mode 100644 index 00000000..6ca97bfd --- /dev/null +++ b/apps/server/src/questions-service-durability-9.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("adding to a card whose authoritative record is closed is refused", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let record = plan.records.get(id)!; + plan.records.set(id, { ...record, status: "cancelled" }); + let source = room.project(plan.document); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + try { + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "closed", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ ok: false, reason: "resolved", rid: "closed" }]); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(plan.document)).toBe(source); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + expect(JSON.stringify(stored?.sidecar)).not.toContain("GitHub Apps"); + } finally { + plan.records.set(id, record); + } +}); + +test("non-string, empty and overlong option labels are refused without a mutation", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let invalid = [ + 123, + ["Nested"], + { text: "Object" }, + " ", + "x".repeat(Question.limits.MAX_LABEL + 1), + ]; + for (let [index, label] of invalid.entries()) { + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-2", + ts: 0, + rid: `invalid-${index}`, + id, + label: label as unknown as string, + }); + } + + expect(frames).toHaveLength(invalid.length); + for (let frame of frames) expect(frame).toMatchObject({ ok: false, reason: "invalid" }); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 0, + ); +}); diff --git a/apps/server/src/questions.anchors.test.ts b/apps/server/src/questions.anchors.test.ts index 6b57db49..1946ad76 100644 --- a/apps/server/src/questions.anchors.test.ts +++ b/apps/server/src/questions.anchors.test.ts @@ -51,7 +51,7 @@ function stored(anchors?: object) { header: "Cache", question: "How long do we cache?", multiple: false, - options: [], + options: [{ id: "cache-60", label: "60 seconds", description: "" }], }], }, answers: { q1: "60 seconds" }, diff --git a/apps/server/src/questions.service.test.ts b/apps/server/src/questions.service.test.ts index 218c9118..5eb851a5 100644 --- a/apps/server/src/questions.service.test.ts +++ b/apps/server/src/questions.service.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import * as Question from "@chopin/question"; @@ -6,65 +6,18 @@ import * as Questions from "./questions/service"; import * as Store from "./questions/store"; import * as room from "./plan/room"; import * as Service from "./plan/service"; -import { openPlan } from "./testing/plan"; import type { Server } from "bun"; import type { Plan } from "./plan/service"; import type { Socket, SocketData } from "./wire"; -let plans: Plan[] = []; - -afterEach(async () => { - for (let plan of plans) await Service.close(plan); - plans = []; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; }); - -async function opened(source = ""): Promise { - let { plan } = await openPlan(source); - plans.push(plan); - return plan; -} - -function definition(count = 1) { - return Questions.identify({ - questions: Array.from({ length: count }, (_, index) => ({ - header: `Decision ${index + 1}`, - question: `What should decision ${index + 1} be?`, - multiple: false, - options: [{ label: "Choose this", description: "The selected option." }], - })), - }); -} - -function asking( - plan: Plan, - server: Server, - value: ReturnType, - placement?: Parameters[4], -) { - let created = Promise.withResolvers(); - let waiting = Questions.ask(plan, server, "test", value, placement, created.resolve); - return { created: created.promise, waiting }; -} - -async function answer(plan: Plan): Promise { - for (let record of [...plan.records.values()].toReversed()) { - let item = record.definition.questions[0]!; - let opened = Store.snapshot(plan.questions, record.id); - if (!opened.open) throw new Error("question was not open"); - let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) - .fork() as unknown as Question.Model; - model.api.val([item.id, "mode"]).set("choices"); - model.api.val([item.id, "choice"]).set(item.options[0]!.id); - let patch = model.api.flush(); - if (!patch) throw new Error("answer produced no patch"); - let edited = Store.edit(plan.questions, record.id, [...patch.toBinary()]); - if (!edited.open || !edited.accepted) throw new Error("could not save answer"); - let claimed = Store.claimSubmit(plan.questions, record.id, edited.revision, item.header); - if (!claimed.ok) throw new Error("could not settle question"); - Store.commit(plan.questions, claimed.claim); - } -} +plans = fixture.plans; +let { opened, definition, asking, answer } = fixture; test("a batched ask creates independently addressed decision records and nodes", async () => { let plan = await opened(); @@ -119,12 +72,20 @@ test("a batched ask creates independently addressed decision records and nodes", { status: "answered", resolver: "Storage", - answers: [{ question: "Where should room state live?", choices: ["MDX on disk"] }], + answers: [{ + question: "Where should room state live?", + choices: ["MDX on disk"], + optionIds: [records[0]!.definition.questions[0]!.options[0]!.id], + }], }, { status: "answered", resolver: "Scope", - answers: [{ question: "What belongs in the first cut?", choices: ["Anchors"] }], + answers: [{ + question: "What belongs in the first cut?", + choices: ["Anchors"], + optionIds: [records[1]!.definition.questions[0]!.options[0]!.id], + }], }, ]); }); @@ -356,7 +317,11 @@ test("an appended option is durable in the record, draft store and plan before a if (!edited.open || !edited.accepted) throw new Error("could not choose the option"); let claimed = Store.claimSubmit(plan.questions, id, edited.revision, "ana"); if (!claimed.ok) throw new Error("could not claim"); - expect(claimed.answers).toEqual([{ question: item.question, choices: ["A third way"] }]); + expect(claimed.answers).toEqual([{ + question: item.question, + choices: ["A third way"], + optionIds: [reply.option.id], + }]); Store.commit(plan.questions, claimed.claim); await asked.waiting; }); diff --git a/apps/server/src/questions/anchors.test.ts b/apps/server/src/questions/anchors.test.ts index bd79607d..f0c4005b 100644 --- a/apps/server/src/questions/anchors.test.ts +++ b/apps/server/src/questions/anchors.test.ts @@ -27,6 +27,10 @@ function record(over: Partial = {}): Question { return { id: "w1", status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], definition: { questions: [ { id: "q1", header: "Cache", question: "Where do we cache?", multiple: false, options: [] }, 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/questions/card-actions.test.ts b/apps/server/src/questions/card-actions.test.ts new file mode 100644 index 00000000..ee63c305 --- /dev/null +++ b/apps/server/src/questions/card-actions.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; + +import { + captureCardAction, + decisionGeneration, + proseJobTrigger, + restorePendingCardActions, +} from "./card-actions"; + +import type { Record } from "./records"; + +const CARD = "01K0N4W3B7P27CBAEC7A8C8WEA"; +const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEB"; + +function record(history = 0): Record { + return { + id: CARD, + origin: "conversation", + threadId: "thread-a", + status: "open", + definition: { questions: [] }, + history: Array.from({ length: history }, () => ({ choices: [], owner: "ana", at: 1 })), + optionOrigins: {}, + editors: [], + } as Record; +} + +test("card actions capture immutable generations, actors, times and choices", () => { + expect(decisionGeneration(record())).toBe(1); + expect(decisionGeneration(record(1))).toBe(2); + expect(proseJobTrigger(CARD, decisionGeneration(record(1)))).toBe(`decided:${CARD}:2`); + let first = captureCardAction( + record(), + { + kind: "decided", + id: CARD, + actor: "ana", + optionIds: [OPTION], + }, + 9, + "GitHub Apps", + ); + let reopen = captureCardAction(record(), { + kind: "reopened", + id: CARD, + actor: "ben", + }, 10); + let second = captureCardAction( + record(1), + { + kind: "decided", + id: CARD, + actor: "cy", + optionIds: [OPTION], + }, + 11, + "GitHub Apps", + ); + expect([first?.id, reopen?.id, second?.id]).toEqual([ + `card:${CARD}:decided:1`, + `card:${CARD}:reopened:1`, + `card:${CARD}:decided:2`, + ]); + expect([first?.actor, reopen?.actor, second?.actor]).toEqual(["ana", "ben", "cy"]); + expect([first?.at, reopen?.at, second?.at]).toEqual([9, 10, 11]); + expect(first).toMatchObject({ optionIds: [OPTION], text: "GitHub Apps" }); + expect(restorePendingCardActions([first!, reopen!, second!])).toEqual([ + first!, + reopen!, + second!, + ]); +}); + +test("chat-origin and unlinked cards have no mirror action", () => { + expect(captureCardAction({ ...record(), threadId: undefined }, { + kind: "discarded", + id: CARD, + actor: "ana", + }, 9)).toBeUndefined(); + expect(captureCardAction(record(), { + kind: "option-added", + id: CARD, + actor: "chopin", + origin: "chat", + optionId: OPTION, + label: "GitHub Apps", + }, 9)).toBeUndefined(); +}); + +test("pending action restore rejects duplicates, missing payload and unbounded actors", () => { + let action = captureCardAction( + record(), + { + kind: "decided", + id: CARD, + actor: "ana", + optionIds: [OPTION], + }, + 9, + "GitHub Apps", + )!; + expect(() => restorePendingCardActions([action, action])).toThrow(/card actions/); + expect(() => restorePendingCardActions([{ ...action, text: undefined }])).toThrow( + /card actions/, + ); + expect(() => restorePendingCardActions([{ ...action, actor: "x".repeat(201) }])).toThrow( + /card actions/, + ); +}); diff --git a/apps/server/src/questions/card-actions.ts b/apps/server/src/questions/card-actions.ts new file mode 100644 index 00000000..e06cc2bb --- /dev/null +++ b/apps/server/src/questions/card-actions.ts @@ -0,0 +1,159 @@ +import { ULID } from "@chopin/dialect"; +import * as Question from "@chopin/question"; + +import type { Record as CardRecord } from "./records"; +import type { CardEvent } from "./card-event"; + +const MAX_PENDING_CARD_ACTIONS = 1024; +const MAX_DECISION_TEXT = 500; + +type Base = { + id: string; + cardId: string; + threadId: string; + actor: string; + at: number; +}; + +/** Stable identity of the currently saved decision; reopen records the prior answer in history. */ +export function decisionGeneration(record: Pick): number { + return record.history.length + 1; +} + +export function proseJobTrigger(cardId: string, generation: number): string { + return `decided:${cardId}:${generation}`; +} + +export type PendingCardAction = + & Base + & ( + | { kind: "decided"; generation: number; optionIds: string[]; text: string } + | { kind: "reopened"; generation: number } + | { kind: "discarded" } + | { kind: "option-added"; optionId: string; label: string; origin: "human" | "planner" } + ); + +/** Capture the event before the card's fenced commit, using card history rather than observer state. */ +export function captureCardAction( + record: CardRecord, + event: CardEvent, + at: number, + text?: string, +): PendingCardAction | undefined { + if (!record.threadId) return; + if (event.id !== record.id) throw new Error("card action does not match its record"); + let base = { + cardId: record.id, + threadId: record.threadId, + actor: event.actor, + at, + }; + switch (event.kind) { + case "decided": { + if (!text) throw new Error("card decision text is missing"); + let generation = decisionGeneration(record); + let summary = text.length > MAX_DECISION_TEXT + ? `${text.slice(0, MAX_DECISION_TEXT - 1).replace(/[\uD800-\uDBFF]$/, "")}…` + : text; + return { + ...base, + id: `card:${record.id}:decided:${generation}`, + kind: "decided", + generation, + optionIds: [...event.optionIds], + text: summary, + }; + } + case "reopened": { + let generation = decisionGeneration(record); + return { + ...base, + id: `card:${record.id}:reopened:${generation}`, + kind: "reopened", + generation, + }; + } + case "discarded": + return { ...base, id: `card:${record.id}:discarded`, kind: "discarded" }; + case "option-added": + if (event.origin === "chat") return; + return { + ...base, + id: `card:${record.id}:option:${event.optionId}`, + kind: "option-added", + optionId: event.optionId, + label: event.label, + origin: event.origin, + }; + } +} + +export function appendCardAction( + pending: readonly PendingCardAction[], + action: PendingCardAction | undefined, +): PendingCardAction[] { + if (!action) return [...pending]; + if (pending.some(item => item.id === action.id)) throw new Error("duplicate pending card action"); + if (pending.length >= MAX_PENDING_CARD_ACTIONS) throw new Error("pending card actions are full"); + return [...pending, action]; +} + +function invalid(): never { + throw new Error("hosted channel has invalid card actions"); +} + +function bounded(value: unknown, max: number): value is string { + return typeof value === "string" && !!value.trim() && value.length <= max; +} + +function exact(value: Record, keys: string[]): boolean { + return Object.keys(value).sort().join(",") === keys.sort().join(","); +} + +/** Legacy sidecars may omit the queue; present entries must be complete and strictly bounded. */ +export function restorePendingCardActions(value: unknown): PendingCardAction[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_PENDING_CARD_ACTIONS) invalid(); + let ids = new Set(); + for (let raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) invalid(); + let item = raw as Record; + let base = ["id", "cardId", "threadId", "actor", "at", "kind"]; + if ( + !bounded(item.cardId, 200) || !ULID.test(item.cardId) + || !bounded(item.threadId, 200) || !bounded(item.actor, 200) + || !Number.isSafeInteger(item.at) || (item.at as number) < 0 + || !bounded(item.id, 200) || ids.has(item.id) + ) invalid(); + ids.add(item.id); + if (item.kind === "decided") { + if ( + !exact(item, [...base, "generation", "optionIds", "text"]) + || !Number.isSafeInteger(item.generation) || (item.generation as number) < 1 + || item.id !== `card:${item.cardId}:decided:${item.generation}` + || !Array.isArray(item.optionIds) + || item.optionIds.length > Question.limits.MAX_DECISION_OPTIONS + || item.optionIds.some(id => typeof id !== "string" || !ULID.test(id)) + || new Set(item.optionIds).size !== item.optionIds.length + || !bounded(item.text, MAX_DECISION_TEXT) + ) invalid(); + } else if (item.kind === "reopened") { + if ( + !exact(item, [...base, "generation"]) + || !Number.isSafeInteger(item.generation) || (item.generation as number) < 1 + || item.id !== `card:${item.cardId}:reopened:${item.generation}` + ) invalid(); + } else if (item.kind === "discarded") { + if (!exact(item, base) || item.id !== `card:${item.cardId}:discarded`) invalid(); + } else if (item.kind === "option-added") { + if ( + !exact(item, [...base, "optionId", "label", "origin"]) + || typeof item.optionId !== "string" || !ULID.test(item.optionId) + || item.id !== `card:${item.cardId}:option:${item.optionId}` + || !bounded(item.label, Question.limits.MAX_LABEL) + || !["human", "planner"].includes(item.origin as string) + ) invalid(); + } else invalid(); + } + return value as PendingCardAction[]; +} diff --git a/apps/server/src/questions/card-event.ts b/apps/server/src/questions/card-event.ts new file mode 100644 index 00000000..89c42aaa --- /dev/null +++ b/apps/server/src/questions/card-event.ts @@ -0,0 +1,13 @@ +export type CardEvent = + | { kind: "decided"; id: string; threadId?: string; actor: string; optionIds: string[] } + | { kind: "reopened"; id: string; threadId?: string; actor: string } + | { kind: "discarded"; id: string; threadId?: string; actor: string } + | { + kind: "option-added"; + id: string; + threadId?: string; + actor: string; + optionId: string; + label: string; + origin: "chat" | "planner" | "human"; + }; diff --git a/apps/server/src/questions/card-insertion.ts b/apps/server/src/questions/card-insertion.ts new file mode 100644 index 00000000..e907f63c --- /dev/null +++ b/apps/server/src/questions/card-insertion.ts @@ -0,0 +1,133 @@ +import { ULID, ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { isDeepStrictEqual } from "node:util"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function insertConversationCard( + plan: Plan, + server: Server, + roomId: string, + input: { + threadId: string; + header: string; + question: string; + options: Array<{ id: string; label: string; source?: ConversationPlan.SourceRef }>; + }, +): Promise { + let found = [...plan.records.values()].find(record => record.threadId === input.threadId); + if (found) return found.id; + return Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let existing = [...plan.records.values()].find(record => record.threadId === input.threadId); + if (existing) return existing.id; + let id = ulid(); + let questionId = ulid(); + let options = input.options.slice(0, Question.limits.MAX_DECISION_OPTIONS).map(option => { + if (!ULID.test(option.id)) Question.reject("Conversation option ID is invalid"); + if (option.source) { + if ( + option.source.role !== "option" + || !plan.conversationPlan.events.some(event => + event.type === "option.added" && event.threadId === input.threadId + && event.contribution.id === option.id + && event.contribution.text.trim().slice(0, Question.limits.MAX_LABEL) + === option.label.trim() + && isDeepStrictEqual(event.source, option.source) + ) + ) Question.reject("Conversation option source is invalid"); + let entry = plan.chat.entries.find(item => item.id === option.source?.messageId); + if (!entry) Question.reject("Conversation option source is missing"); + validateSource(option.source, entry); + } + return { + id: option.id, + label: option.label.slice(0, Question.limits.MAX_LABEL), + description: "", + }; + }); + let single = Question.decision(Question.identified({ + questions: [{ + id: questionId, + header: input.header.slice(0, Question.limits.MAX_HEADER), + question: input.question.slice(0, Question.limits.MAX_QUESTION), + multiple: false, + options, + }], + })); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + Store.reopen(stagedQuestions, id, single, id); + let records = new Map(plan.records); + records.set(id, { + id, + definition: single, + status: "open", + origin: "conversation", + threadId: input.threadId, + history: [], + optionOrigins: Object.fromEntries(options.map(option => { + let source = input.options.find(item => item.id === option.id)?.source; + return [option.id, { origin: "chat", ...(source ? { source } : {}) }]; + })), + editors: [], + }); + let mutation = room.insertQuestionnaires(stagedDocument, [{ + value: { + id, + thread: input.threadId, + status: "open", + questions: [{ + id: questionId, + header: single.questions[0].header, + prompt: single.questions[0].question, + multiple: false, + options: options.map(option => ({ id: option.id, label: option.label })), + }], + }, + }]); + if (!mutation) throw new Error("conversation card could not be inserted"); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + }, mutation); + } finally { + stagedDocument.doc.destroy(); + } + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id, + definition: single, + widget: id, + }); + announce(plan, server, roomId, id); + return id; + }); +} diff --git a/apps/server/src/questions/card-involved.ts b/apps/server/src/questions/card-involved.ts new file mode 100644 index 00000000..b7d07c41 --- /dev/null +++ b/apps/server/src/questions/card-involved.ts @@ -0,0 +1,37 @@ +import * as Store from "./store"; +import type { Plan } from "../plan/service"; +import type { Record } from "./records"; + +/** Owner first, then card editors and the conversation's member participants. */ +export function involved(plan: Plan, record: Record): string[] { + let live = Store.get(plan.questions, record.id)?.editors ?? new Set(); + let thread = plan.conversationPlan.threads.find(item => + item.questionnaireId === record.id || item.id === record.threadId + ); + let sources = thread + ? [ + ...thread.questionSources, + ...thread.contributions.flatMap(item => item.sources), + ...thread.stances.flatMap(item => item.sources), + ...thread.stanceHistory.flatMap(item => item.sources), + ...(thread.decision?.sources ?? []), + ...thread.decisionHistory.flatMap(item => item.sources), + ...thread.candidates.flatMap(item => item.sources), + ] + : []; + let authors = sources.flatMap(source => + source.author.kind === "member" ? [source.author.handle] : [] + ); + let stances = thread + ? [...thread.stances, ...thread.stanceHistory].map(item => item.participant) + : []; + return [ + ...new Set([ + ...(record.owner ? [record.owner] : []), + ...record.editors, + ...live, + ...authors, + ...stances, + ]), + ].slice(0, 8); +} diff --git a/apps/server/src/questions/card-notifications.ts b/apps/server/src/questions/card-notifications.ts new file mode 100644 index 00000000..1df498da --- /dev/null +++ b/apps/server/src/questions/card-notifications.ts @@ -0,0 +1,82 @@ +import { appendCardAction, captureCardAction } from "./card-actions"; + +import * as Prose from "./prose"; + +import * as Store from "./store"; +import * as Jobs from "../conversation-plan/jobs"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire } from "@chopin/protocol"; + +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import type { Record } from "./records"; + +import { involved } from "./card-involved"; +import type { CardEvent } from "./card-event"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +const listeners = new WeakMap void>>(); + +export function listen(plan: Plan, listener: (event: CardEvent) => void): () => void { + let set = listeners.get(plan); + if (!set) listeners.set(plan, set = new Set()); + set.add(listener); + return () => set.delete(listener); +} + +export function emit(plan: Plan, event: CardEvent): void { + for (let listener of listeners.get(plan) ?? []) { + try { + listener(event); + } catch (err) { + console.error("[questions] a card listener failed:", err); + } + } +} + +export function pending( + plan: Plan, + record: Record, + event: CardEvent, + at = Math.floor(Date.now() / 1_000), + text?: string, +) { + return appendCardAction(plan.pendingCardActions, captureCardAction(record, event, at, text)); +} + +function cardStatus(record: Record): Wire.CardStatus { + if (record.status === "answered") return "decided"; + if (record.status === "cancelled") return "discarded"; + return record.status; +} + +export function meta(plan: Plan, record: Record): Wire.CardMeta { + let prose = record.prose ?? []; + let decided = record.status === "answered"; + let suggested = Store.get(plan.questions, record.id)?.suggested; + return { + status: cardStatus(record), + origin: record.origin, + ...(record.threadId ? { thread: record.threadId } : {}), + ...(decided && record.owner ? { owner: record.owner } : {}), + ...(decided && record.decidedAt !== undefined ? { decidedAt: record.decidedAt } : {}), + ...(record.resolver ? { resolver: record.resolver } : {}), + involved: involved(plan, record), + ...(suggested ? { suggested } : {}), + history: record.history, + optionOrigins: record.optionOrigins, + refining: Jobs.refining(plan.conversationPlanJobs, record.id), + hasProse: prose.length > 0 && !Prose.orphaned(prose), + proseOrphaned: Prose.orphaned(prose), + }; +} + +export function announce(plan: Plan, server: Server, roomId: string, id: string): void { + let record = plan.records.get(id); + if (record) { + broadcast(server, roomId, { kind: "question:meta", ts: 0, id, meta: meta(plan, record) }); + } +} diff --git a/apps/server/src/questions/card-option-relabel.ts b/apps/server/src/questions/card-option-relabel.ts new file mode 100644 index 00000000..b7202f95 --- /dev/null +++ b/apps/server/src/questions/card-option-relabel.ts @@ -0,0 +1,163 @@ +import * as Y from "yjs"; + +import { applyEvent } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function relabelConversationOption( + plan: Plan, + server: Server, + roomId: string, + input: { + operationId: string; + threadId: string; + optionId: string; + label: string; + expectedThreadVersion: number; + expectedCardRevision: number; + }, +): Promise<{ eventId: string; revision: number }> { + return Service.exclusive(plan, async () => { + if ( + typeof input.operationId !== "string" || !input.operationId + || input.operationId.length > 180 + ) throw new Error("invalid relabel operation ID"); + let eventId = `option-relabel:${input.operationId}`; + let previous = plan.conversationPlan.events.find(event => event.id === eventId); + if (previous) { + if ( + previous.type !== "option.relabeled" || previous.threadId !== input.threadId + || previous.optionId !== input.optionId || previous.label !== input.label + || previous.observedThreadVersion !== input.expectedThreadVersion + || previous.observedCardRevision !== input.expectedCardRevision + ) { + throw new Error("relabel operation ID collision"); + } + return { eventId, revision: plan.conversationPlan.revision }; + } + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let thread = plan.conversationPlan.threads.find(item => item.id === input.threadId); + if ( + !thread || !thread.questionnaireId || thread.version !== input.expectedThreadVersion + || thread.status === "decided" || thread.status === "discarded" + ) { + throw new Error("conversation decision is no longer open and linked"); + } + let option = thread.contributions.find(item => + item.id === input.optionId && item.kind === "option" + ); + let record = plan.records.get(thread.questionnaireId); + let live = Store.get(plan.questions, thread.questionnaireId); + if ( + !option || option.sources.length === 0 || !record || record.origin !== "conversation" + || record.threadId !== thread.id || record.optionOrigins[input.optionId]?.origin !== "chat" + || !isOpenStatus(record.status) || !live || live.claim + || live.revision !== input.expectedCardRevision + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !record.definition.questions[0]?.options.some(item => item.id === input.optionId) + || !room.hasQuestionnaire(plan.document, record.id, record.definition.questions[0]!.id) + ) { + throw new Error("conversation option is no longer editable"); + } + for ( + let source of [ + ...option.sources, + ...(record.optionOrigins[input.optionId]?.source + ? [record.optionOrigins[input.optionId]!.source!] + : []), + ] + ) { + let message = plan.chat.entries.find(item => item.id === source.messageId); + if (!message) throw new Error("option source message is missing"); + validateSource(source, message); + } + let event: ConversationPlan.Event = { + id: eventId, + type: "option.relabeled", + threadId: thread.id, + observedThreadVersion: input.expectedThreadVersion, + observedCardRevision: input.expectedCardRevision, + origin: "planner", + actor: { kind: "agent" }, + at: Date.now(), + optionId: input.optionId, + label: input.label, + }; + let conversationPlan = applyEvent(plan.conversationPlan, event); + if (!Store.reserveEdit(plan.questions, record.id)) { + throw new Error("conversation option is no longer editable"); + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + stagedQuestions.open.set(record.id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let relabeled = Store.relabelOption( + stagedQuestions, + record.id, + input.optionId, + input.label, + input.expectedCardRevision, + ); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let mutation = room.projectOptions( + stagedDocument, + record.id, + relabeled.definition.questions[0]!, + ); + if (!mutation) throw new Error("conversation card label was not projected"); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + conversationPlan, + records: new Map(plan.records).set(record.id, { + ...record, + definition: relabeled.definition, + }), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id: record.id, + definition: relabeled.definition, + revision: relabeled.revision, + }); + broadcast(server, roomId, { + kind: "conversation-plan:changed", + ts: 0, + state: conversationPlan, + }); + return { eventId, revision: conversationPlan.revision }; + } finally { + Store.releaseEdit(plan.questions, record.id); + stagedDocument?.doc.destroy(); + } + }); +} diff --git a/apps/server/src/questions/card-planner-revision.ts b/apps/server/src/questions/card-planner-revision.ts new file mode 100644 index 00000000..528ec3a3 --- /dev/null +++ b/apps/server/src/questions/card-planner-revision.ts @@ -0,0 +1,145 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import { appendCardAction, captureCardAction } from "./card-actions"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce, emit } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function revisePlannerCard( + plan: Plan, + server: Server, + roomId: string, + id: string, + input: { title?: string; addOptions: Array<{ label: string; rationale: string }> }, + locked = false, + beforePublish?: () => void, +): Promise<{ title: string; added: string[] }> { + let body = async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, id, record.definition.questions[0]!.id) + ) throw new Error("that decision is no longer open in this document"); + if (!Store.reserveEdit(originalQuestions, id)) { + throw new Error("that decision is no longer open in this document"); + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let title = record.definition.questions[0]!.question; + if (input.title !== undefined) { + let retitled = Store.retitle(stagedQuestions, id, input.title); + if (!retitled.ok) throw new Error(retitled.message); + title = retitled.definition.questions[0]!.question; + } + let added: Array<{ id: string; label: string }> = []; + let origins = { ...record.optionOrigins }; + let actions = plan.pendingCardActions; + let at = Math.floor(Date.now() / 1_000); + for (let option of input.addOptions) { + let result = Store.addOption(stagedQuestions, id, ulid(), option.label); + if (!result.ok) throw new Error(`option addition was refused: ${result.reason}`); + added.push({ id: result.option.id, label: result.option.label }); + origins[result.option.id] = { origin: "planner", rationale: option.rationale }; + actions = appendCardAction( + actions, + captureCardAction(record, { + kind: "option-added", + id, + actor: "chopin", + optionId: result.option.id, + label: result.option.label, + origin: "planner", + }, at), + ); + } + let final = Store.get(stagedQuestions, id)!; + if (title === record.definition.questions[0]!.question && added.length === 0) { + return { title, added: [] }; + } + let vector = Y.encodeStateVector(plan.document.doc); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let question = final.definition.questions[0]!; + let definition = Question.decision(final.definition); + if (title !== record.definition.questions[0]!.question) { + room.projectPrompt(stagedDocument, id, question.id, title); + } + if (added.length > 0) room.projectOptions(stagedDocument, id, question); + let mutation: room.Mutation = { + update: Y.encodeStateAsUpdate(stagedDocument.doc, vector), + source: room.project(stagedDocument), + }; + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records: new Map(plan.records).set(id, { + ...record, + definition, + optionOrigins: origins, + }), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + pendingCardActions: actions, + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition, + revision: final.revision, + }); + announce(plan, server, roomId, id); + for (let option of added) { + emit(plan, { + kind: "option-added", + id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: "chopin", + optionId: option.id, + label: option.label, + origin: "planner", + }); + } + return { title, added: added.map(option => option.id) }; + } finally { + Store.releaseEdit(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/card-reopen-1.test.ts b/apps/server/src/questions/card-reopen-1.test.ts new file mode 100644 index 00000000..a3dec822 --- /dev/null +++ b/apps/server/src/questions/card-reopen-1.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "bun:test"; + +import * as room from "../plan/room"; + +import { openPlan, storedQuestion } from "../testing/plan"; +import * as Questions from "./service"; +import * as Store from "./store"; + +import { decided, definition, member, reopen } from "./card-reopen.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("reopen keeps identity, moves the decision to history, and publishes metadata before asked", async () => { + let context = await decided(); + let original = room.project(context.plan.document); + let actor = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + await reopen(context, actor); + off(); + expect(actor.frames.at(-1)).toMatchObject({ kind: "question:reopen", ok: true }); + let record = context.plan.records.get(context.id)!; + expect(record).toMatchObject({ + status: "reopened", + history: [{ + choices: [context.value.questions[0]!.options[0]!.id], + owner: "ana", + at: 1, + }], + }); + expect(record.answers).toBeUndefined(); + expect(record.choices).toBeUndefined(); + expect(Store.get(context.plan.questions, context.id)?.settle).toBeUndefined(); + expect(Store.get(context.plan.questions, context.id)?.revision).toBe(0); + let projected = room.project(context.plan.document); + expect(projected).toContain('status="reopened"'); + expect(projected).toContain( + ` frame.kind)).toEqual([ + "plan:update", + "question:meta", + "question:asked", + ]); + expect(events).toEqual([{ kind: "reopened", id: context.id, actor: "ben" }]); +}); + +test("a failed reopen commit preserves the answer, tombstone and history; retry commits once", async () => { + let context = await decided(); + let question = context.value.questions[0]!; + context.plan.questions.closed.set(context.id, { + result: { + status: "answered", + answers: [{ + question: question.question, + choices: [question.options[0]!.label], + optionIds: [question.options[0]!.id], + }], + resolver: "ana", + }, + revision: 1, + expires: Date.now() + 60_000, + }); + let before = room.project(context.plan.document); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + let old = context.plan.records.get(context.id)!; + let closed = context.plan.questions.closed.get(context.id); + let frames = context.broadcasts.length; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + let actor = await reopen(context); + expect(actor.frames.at(-1)).toMatchObject({ ok: false, reason: "resolving" }); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(context.id)).toEqual(old); + expect(context.plan.questions.closed.get(context.id)).toBe(closed); + expect(Store.get(context.plan.questions, context.id)).toBeUndefined(); + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(frames); + expect(events).toEqual([]); + expect((await context.storage.collaboration.load(context.channel.id, context.now))?.sidecar) + .toEqual(stored?.sidecar); + await reopen(context); + expect(context.plan.records.get(context.id)?.history).toHaveLength(1); + expect(context.plan.questions.closed.has(context.id)).toBe(false); + off(); +}); + +test("duplicate reopen and reopen after discard are refused", async () => { + let context = await decided(); + let first = member("ben"); + let second = member("cy"); + await Promise.all([ + reopen(context, first), + reopen(context, second), + ]); + expect([first, second].filter(actor => actor.frames.at(-1)?.ok === true)).toHaveLength(1); + expect(context.plan.records.get(context.id)?.history).toHaveLength(1); + await Questions.discard(context.plan, context.server, "test", first.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id: context.id, + }); + await reopen(context, second); + expect(second.frames.at(-1)).toMatchObject({ ok: false, reason: "not-decided" }); +}); + +test("a queued discard follows the reopened draft announcement", async () => { + let context = await decided(); + let actor = member("ben"); + let discarder = member("cy"); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + if (++commits === 1) { + entered.resolve(); + await release.promise; + } + return original(input); + }; + try { + let reopening = reopen(context, actor); + await entered.promise; + let discarding = Questions.discard(context.plan, context.server, "test", discarder.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id: context.id, + }); + release.resolve(); + await Promise.all([reopening, discarding]); + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + let events = context.broadcasts.map( + frame => [frame.kind, (frame.meta as { status?: string })?.status], + ); + let asked = events.findIndex(([kind]) => kind === "question:asked"); + let discarded = events.findIndex(([kind, status]) => + kind === "question:meta" && status === "discarded" + ); + expect(asked).toBeGreaterThan(-1); + expect(discarded).toBeGreaterThan(asked); + expect(context.plan.records.get(context.id)?.status).toBe("discarded"); +}); + +test("a failed metadata broadcast after commit still announces the draft and emits the event", async () => { + let context = await decided(); + let actor = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + context.breakRelay("question:meta"); + await reopen(context, actor); + off(); + expect(actor.frames.at(-1)).toMatchObject({ ok: true }); + expect(context.broadcasts.some(frame => frame.kind === "question:asked")).toBe(true); + expect(events).toEqual([{ kind: "reopened", id: context.id, actor: "ben" }]); + expect(context.plan.records.get(context.id)?.status).toBe("reopened"); +}); + +test("restore refuses a draft whose definition reorders the record's questions", async () => { + let value = definition(2); + let reversed = { questions: value.questions.toReversed() }; + await expect(openPlan("", { + questions: [{ id: "w", definition: value, status: "open" }], + openQuestions: [{ + id: "w", + definition: reversed, + model: storedQuestion(reversed), + revision: 0, + }], + })).rejects.toThrow(/question records disagree with their drafts/); +}); + +test("reopen refuses to clear an answer with no ID or text to preserve", async () => { + let value = definition(2); + let first = value.questions[0]!; + let context = await decided(value, { [first.id]: "Option 1" }, [first.options[0]!.id]); + let before = room.project(context.plan.document); + let actor = await reopen(context); + expect(actor.frames.at(-1)).toMatchObject({ ok: false, reason: "resolving" }); + expect(context.plan.records.get(context.id)?.status).toBe("answered"); + expect(room.project(context.plan.document)).toBe(before); +}); + +test("an older option summary without saved IDs stays text even when its label matches", async () => { + let value = definition(); + let question = value.questions[0]!; + let context = await decided(value, { [question.id]: question.options[0]!.label }, []); + await reopen(context); + expect(context.plan.records.get(context.id)?.history[0]).toEqual({ + choices: [], + answers: { [question.id]: question.options[0]!.label }, + owner: "ana", + at: 1, + }); + expect(room.project(context.plan.document)).toContain( + ` { + let context = await decided(); + let actor = member(); + let before = room.project(context.plan.document); + let gate = Service.exclusive(context.plan, async () => { + context.plan.execution = { id: "run-1" } as never; + }); + let pending = reopen(context, actor); + await Promise.all([gate, pending]); + expect(actor.frames.at(-1)).toMatchObject({ + kind: "session:error", + message: "implementation is active", + }); + expect(context.plan.records.get(context.id)?.status).toBe("answered"); + expect(room.project(context.plan.document)).toBe(before); + context.plan.execution = undefined; +}); + +test("legacy multi-question custom and ID answers survive reopen, restart, and redecision", async () => { + let value = definition(2); + let first = value.questions[0]!; + let second = value.questions[1]!; + let context = await decided(value, { + [first.id]: "Option 1", + [second.id]: "Use our existing provider", + }, [first.options[0]!.id]); + await reopen(context); + let record = context.plan.records.get(context.id)!; + expect(record.history[0]).toEqual({ + choices: [first.options[0]!.id], + answers: { [second.id]: "Use our existing provider" }, + owner: "ana", + at: 1, + }); + let projected = room.project(context.plan.document); + expect(projected).toContain(` { + let value = Questions.identify({ + questions: [0, 1].map(index => ({ + header: `Set ${index + 1}`, + question: `Which options belong in set ${index + 1}?`, + multiple: true, + options: Array.from({ length: 11 }, (_, position) => ({ + label: `Choice ${index + 1}-${position + 1}`, + description: "", + })), + })), + }); + let ids = value.questions.flatMap(question => question.options.map(option => option.id)); + let answers = Object.fromEntries(value.questions.map(question => [ + question.id, + question.options.map(option => option.label).join(", "), + ])); + let context = await decided(value, answers, ids); + await reopen(context); + expect(context.plan.records.get(context.id)?.history[0]?.choices).toHaveLength(22); + await Service.close(context.plan); + plans.splice(plans.indexOf(context.plan), 1); + let plan = await Service.open(context.channel.id, context.backend, context.server); + plans.push(plan); + let snapshot = Store.snapshot(plan.questions, context.id); + if (!snapshot.open) throw new Error("missing restored draft"); + let model = Question.crdt.Model.fromBinary(new Uint8Array(snapshot.model)) + .fork() as unknown as Question.Model; + for (let question of value.questions) { + for (let option of question.options) { + model.api.val([question.id, "options", option.id]).set(true); + } + } + let patch = model.api.flush(); + let actor = member("cy"); + await Questions.edit(plan, actor.ws, { + kind: "question:edit", + ts: 0, + rid: "edit", + id: context.id, + patch: [...patch.toBinary()], + }); + await Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id: context.id, + revision: Store.get(plan.questions, context.id)!.revision, + }); + expect(actor.frames.at(-1)).toMatchObject({ kind: "question:submit", ok: true }); + await Service.close(plan); + plans.splice(plans.indexOf(plan), 1); + let again = await Service.open(context.channel.id, context.backend, context.server); + plans.push(again); + expect(again.records.get(context.id)?.history[0]?.choices).toHaveLength(22); + expect(again.records.get(context.id)?.choices).toHaveLength(22); +}); diff --git a/apps/server/src/questions/card-reopen.test-fixtures.ts b/apps/server/src/questions/card-reopen.test-fixtures.ts new file mode 100644 index 00000000..a2012ca9 --- /dev/null +++ b/apps/server/src/questions/card-reopen.test-fixtures.ts @@ -0,0 +1,110 @@ +import { afterEach } from "bun:test"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import * as Questions from "./service"; + +import type { Socket } from "../wire"; +import type { Plan } from "../plan/service"; +import type { Definition } from "@chopin/question"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 helpers and cleanup. +export let plans: Plan[] = []; + +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + plans = []; +}); + +export function member(handle = "ben") { + let frames: Array> = []; + let ws = { + data: { handle, client: `client-${handle}`, room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +export function definition(count = 1): Definition { + return Questions.identify({ + questions: Array.from({ length: count }, (_, index) => ({ + header: `Choice ${index + 1}`, + question: `Which choice ${index + 1}?`, + multiple: false, + options: [{ label: `Option ${index + 1}`, description: "" }], + })), + }); +} + +export function source( + id: string, + value: Definition, + answers: { [question: string]: string }, + chosen: string[], +) { + let selected = new Set(chosen); + return `\n` + + value.questions.map(question => { + let options = question.options.map(option => + `\n"; +} + +export async function decided( + value = definition(), + answers?: { [question: string]: string }, + chosen?: string[], +) { + let id = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + let text = answers ?? Object.fromEntries(value.questions.map(question => [ + question.id, + question.options[0]!.label, + ])); + let ids = chosen ?? value.questions.map(question => question.options[0]!.id); + let context = await openPlan(source(id, value, text, ids), { + questions: [{ + id, + definition: value, + status: "answered", + answers: text, + choices: ids, + resolver: "ana", + at: 1, + owner: "ana", + decidedAt: 1, + }], + }); + plans.push(context.plan); + return { ...context, id, value }; +} + +export async function reopen( + context: Awaited>, + member = memberFactory(), +) { + await Questions.reopen(context.plan, context.server, "test", member.ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id: context.id, + }); + return member; +} + +export function memberFactory() { + return member(); +} diff --git a/apps/server/src/questions/card-retitle.ts b/apps/server/src/questions/card-retitle.ts new file mode 100644 index 00000000..729b9b7a --- /dev/null +++ b/apps/server/src/questions/card-retitle.ts @@ -0,0 +1,93 @@ +import * as Y from "yjs"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function retitle( + plan: Plan, + server: Server, + roomId: string, + id: string, + title: string, + locked = false, + beforePublish?: () => void, +): Promise<{ ok: true; revision: number } | { ok: false; message: string }> { + let body = async () => { + if (Service.implementationActive(plan)) { + return { ok: false as const, message: "implementation is active" }; + } + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, id, record.definition.questions[0]!.id) + ) return { ok: false as const, message: "This decision is no longer open" }; + if (!Store.reserveEdit(originalQuestions, id)) { + return { ok: false as const, message: "This decision is no longer open" }; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let result = Store.retitle(stagedQuestions, id, title); + if (!result.ok) return { ok: false as const, message: result.message }; + if (!result.applied) return { ok: true as const, revision: result.revision }; + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(id, { ...record, definition: result.definition }); + let question = result.definition.questions[0]!; + let mutation = room.projectPrompt(stagedDocument, id, question.id, question.question); + if (!mutation) throw new Error("card prompt did not change the document"); + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition: result.definition, + revision: result.revision, + }); + announce(plan, server, roomId, id); + return { ok: true as const, revision: result.revision }; + } finally { + Store.releaseEdit(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/card-server-options.ts b/apps/server/src/questions/card-server-options.ts new file mode 100644 index 00000000..39f1c59e --- /dev/null +++ b/apps/server/src/questions/card-server-options.ts @@ -0,0 +1,236 @@ +import { ULID, ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { isDeepStrictEqual } from "node:util"; + +import { applyEvent } from "../conversation-plan/events"; +import { OptionCapacityError } from "../conversation-plan/option-capacity"; + +import { isOpenStatus, matchesQuestionSource, questionMentionsOption } from "./records"; +import { atomicOptionQuote } from "./option-match"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function addServerOption( + plan: Plan, + server: Server, + roomId: string, + id: string, + input: { + optionId?: string; + label: string; + origin: "chat" | "planner"; + rationale?: string; + source?: ConversationPlan.SourceRef; + trigger?: string; + }, + locked = false, + beforePublish?: () => void, +): Promise< + { ok: true; optionId: string } | { + ok: false; + reason: "full" | "duplicate" | "invalid" | "closed"; + } +> { + let body = async () => { + if (Service.implementationActive(plan)) { + return { ok: false as const, reason: "closed" as const }; + } + let record = plan.records.get(id); + let live = Store.get(plan.questions, id); + if (!record || !isOpenStatus(record.status) || !live || live.claim) { + return { ok: false as const, reason: "closed" as const }; + } + if ( + typeof input.label !== "string" || !input.label.trim() + || typeof input.optionId !== "undefined" && !ULID.test(input.optionId) + || input.rationale !== undefined && (typeof input.rationale !== "string" + || !input.rationale.trim() || input.rationale.length > 1_000) + ) return { ok: false as const, reason: "invalid" as const }; + if (input.source !== undefined) { + try { + if ( + input.origin === "chat" && ( + input.source.role !== "option" + || !plan.conversationPlan.events.some(event => + event.id === input.trigger && event.type === "option.added" + && event.threadId === record.threadId + && event.contribution.id === input.optionId + && event.contribution.text.trim().slice(0, Question.limits.MAX_LABEL) + === input.label.trim() + && isDeepStrictEqual(event.source, input.source) + ) + ) + ) return { ok: false as const, reason: "invalid" as const }; + if ( + input.origin === "planner" && ( + input.source.author.kind !== "member" + || input.source.role === "option" + && !atomicOptionQuote(input.source.quote, input.label) + ) + ) return { ok: false as const, reason: "invalid" as const }; + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === id + ); + if ( + input.origin === "planner" && ( + input.source.role !== "option" + && (!matchesQuestionSource(input.source, thread) + || !questionMentionsOption(input.source.quote, input.label)) + ) + ) { + return { ok: false as const, reason: "invalid" as const }; + } + let message = plan.chat.entries.find(entry => entry.id === input.source?.messageId); + if (!message) return { ok: false as const, reason: "invalid" as const }; + validateSource(input.source, message); + } catch { + return { ok: false as const, reason: "invalid" as const }; + } + } + let already = record.definition.questions[0]?.options.find(option => + option.id === input.optionId + ); + if (already) { + let provenance = record.optionOrigins[already.id]; + if ( + already.label === input.label.trim() && provenance?.origin === input.origin + && provenance.rationale === input.rationale + && JSON.stringify(provenance.source) === JSON.stringify(input.source) + ) return { ok: true as const, optionId: already.id }; + return { ok: false as const, reason: "invalid" as const }; + } + let originalQuestions = plan.questions; + if (!Store.reserveOption(originalQuestions, id)) { + return { ok: false as const, reason: "closed" as const }; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let added = Store.addOption(stagedQuestions, id, input.optionId ?? ulid(), input.label); + if (!added.ok) return { ok: false as const, reason: added.reason }; + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(id, { + ...record, + definition: added.definition, + optionOrigins: { + ...record.optionOrigins, + [added.option.id]: { + origin: input.origin, + ...(input.rationale ? { rationale: input.rationale } : {}), + ...(input.source ? { source: input.source } : {}), + }, + }, + }); + let mutation = room.projectOptions( + stagedDocument, + id, + added.definition.questions[0], + ); + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === id + ); + let optionEvent: ConversationPlan.Event | undefined = input.origin === "planner" && thread + ? { + id: `card:${id}:option:${added.option.id}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "planner", + actor: { kind: "agent" }, + at: Math.floor(Date.now() / 1_000), + ...(input.source?.role === "option" ? { source: input.source } : {}), + contribution: { + id: added.option.id, + text: added.option.label, + authoring: "scribe", + targetId: thread.id, + }, + } + : undefined; + let conversationPlan = optionEvent + ? applyEvent(plan.conversationPlan, optionEvent) + : plan.conversationPlan; + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + conversationPlan, + pendingCardActions: optionEvent ? plan.pendingCardActions : pending(plan, record, { + kind: "option-added", + id, + actor: "chopin", + optionId: added.option.id, + label: added.option.label, + origin: input.origin, + }), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition: added.definition, + revision: added.revision, + }); + if (optionEvent) { + broadcast(server, roomId, { + kind: "conversation-plan:changed", + ts: 0, + state: conversationPlan, + }); + } + announce(plan, server, roomId, id); + if (input.origin === "planner") { + emit(plan, { + kind: "option-added", + id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: "chopin", + optionId: added.option.id, + label: added.option.label, + origin: "planner", + }); + } + return { ok: true as const, optionId: added.option.id }; + } finally { + Store.releaseOption(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + try { + return await (locked ? body() : Service.exclusive(plan, body)); + } catch (error) { + if (error instanceof OptionCapacityError) return { ok: false, reason: error.reason }; + throw error; + } +} diff --git a/apps/server/src/questions/card-suggestions.ts b/apps/server/src/questions/card-suggestions.ts new file mode 100644 index 00000000..2885d771 --- /dev/null +++ b/apps/server/src/questions/card-suggestions.ts @@ -0,0 +1,57 @@ +import { isOpenStatus } from "./records"; + +import * as Store from "./store"; + +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function suggest( + plan: Plan, + server: Server, + roomId: string, + id: string, + suggestion?: { optionId: string; messageIds: string[] }, + locked = false, +): Promise { + let body = async () => { + if (Service.implementationActive(plan)) return false; + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || !Store.reserveEdit(originalQuestions, id) + ) { + return false; + } + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let result = Store.suggest(stagedQuestions, id, suggestion); + if (!result.ok) return false; + if (result.revision === live.revision) return true; + await Service.publishStaged(plan, server, roomId, { + ...plan, + questions: stagedQuestions, + records: new Map(plan.records), + }); + announce(plan, server, roomId, id); + return true; + } finally { + Store.releaseEdit(originalQuestions, id); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/conversation-capacity.test.ts b/apps/server/src/questions/conversation-capacity.test.ts new file mode 100644 index 00000000..78b99489 --- /dev/null +++ b/apps/server/src/questions/conversation-capacity.test.ts @@ -0,0 +1,314 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Questions from "./service"; +import * as Store from "./store"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlan } from "../testing/plan"; +import { createProcessor } from "../conversation-plan/service"; +import { mirrorCard } from "../conversation-plan/card-mirror-runner"; +import { MAX_EVENTS } from "../conversation-plan/validation"; +import { applyEvent } from "../conversation-plan/events"; +import { opened, unlinked } from "../conversation-plan/service.test-fixtures"; +import type { Socket } from "../wire"; + +async function ask(context: Awaited>) { + let created = Promise.withResolvers(); + let waiting = Questions.ask( + context.plan, + context.server, + context.plan.id, + Questions.identify({ + questions: [{ + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [{ label: "Managed service", description: "" }], + }], + }), + undefined, + created.resolve, + ); + await Promise.race([created.promise, waiting]); + return [...context.plan.records.values()].at(-1)!; +} + +function fill(context: Awaited>, remaining: number) { + // Restoration replays all 4096 events, so keep filler IDs short and leave card threads untouched. + let threadId = "filler"; + let state = applyEvent(context.plan.conversationPlan, { + id: "filler:opened", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 0, + question: "Filler thread?", + }); + let thread = state.threads.at(-1)!; + while (state.events.length < MAX_EVENTS - remaining) { + state.events.push({ + id: String(state.events.length), + type: "thread.discarded", + threadId: thread.id, + observedThreadVersion: thread.version++, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: state.events.length, + }); + thread.status = "discarded"; + state.revision++; + } + context.plan.conversationPlan = state; +} + +function member(context: Awaited>) { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: context.plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +async function select(context: Awaited>, ws: Socket, id: string) { + let entry = Store.get(context.plan.questions, id)!; + let model = entry.model.fork(); + let question = entry.definition.questions[0]!; + model.api.val([question.id, "choice"]).set(question.options[0]!.id); + let patch = model.api.flush()!; + await Questions.edit(context.plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); +} + +for (let action of ["submit", "cancel"] as const) { + test(`full event history rejects linked ${action} without changing authority or draft`, async () => { + let context = await openPlan(); + try { + let record = await ask(context); + let { ws, frames } = member(context); + if (action === "submit") await select(context, ws, record.id); + let accepted = context.plan.records.get(record.id); + fill(context, 0); + await Plan.persist(context.plan); + let source = Room.project(context.plan.document); + let epoch = context.plan.document.epoch; + let seq = context.plan.document.seq; + let draft = Store.snapshot(context.plan.questions, record.id); + let revision = context.plan.persistence.revision; + frames.length = 0; + if (action === "submit") { + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: action, + id: record.id, + revision: Store.get(context.plan.questions, record.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: action, + id: record.id, + }); + } + expect(context.plan.records.get(record.id)).toEqual(accepted); + expect(Store.snapshot(context.plan.questions, record.id)).toEqual(draft); + expect(Store.get(context.plan.questions, record.id)?.claim).toBeUndefined(); + expect(Room.project(context.plan.document)).toBe(source); + expect([context.plan.document.epoch, context.plan.document.seq]).toEqual([epoch, seq]); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.persistence.revision).toBe(revision); + expect(frames).toMatchObject([{ + kind: "session:error", + rid: action, + message: "Conversation history is full", + }]); + } finally { + await Plan.close(context.plan); + } + }); +} + +for (let action of ["submit", "cancel"] as const) { + test(`the last event slot is reserved for ${action} across restart`, async () => { + let context = await openPlan(); + let errors: unknown[] = []; + let processor: ReturnType | undefined; + try { + let first = await ask(context); + let second = await ask(context); + let { ws, frames } = member(context); + if (action === "submit") await select(context, ws, first.id); + fill(context, 1); + if (action === "submit") { + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: "first", + id: first.id, + revision: Store.get(context.plan.questions, first.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "first", + id: first.id, + }); + } + expect(context.plan.pendingCardActions).toHaveLength(1); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "second", + id: second.id, + }); + expect(context.plan.records.get(second.id)?.status).toBe("open"); + expect(context.plan.pendingCardActions).toHaveLength(1); + expect(frames.at(-1)).toMatchObject({ kind: "session:error", rid: "second" }); + processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + onError: error => errors.push(error), + interpret: async input => ({ + ...unlinked(), + events: [opened(input.message)], + analysis: { ...unlinked().analysis, status: "applied" }, + }), + }); + let previous = context.plan.conversationPlan; + let fatal = spyOn(context.plan.persistence, "fatal"); + try { + await expect(processor.record({ + id: "unrelated", + type: "thread.opened", + threadId: "unrelated", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 0, + question: "Another question?", + })).rejects.toThrow("Conversation history is full"); + expect(context.plan.conversationPlan).toBe(previous); + expect(fatal).not.toHaveBeenCalled(); + } finally { + fatal.mockRestore(); + } + await processor.accept({ + id: "m-capacity", + text: "Can we ship?", + ts: 1000, + author: { kind: "member", handle: "ana" }, + }); + processor.afterMessage(); + await processor.idle(); + expect(errors).toEqual([]); + expect(context.plan.conversationPlan.queue).toMatchObject([{ + messageId: "m-capacity", + status: "failed", + }]); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 1); + let unlinkedCard = await ask(context); + expect(unlinkedCard.threadId).toBeUndefined(); + processor.stop(); + await processor.idle(); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.get(unlinkedCard.id)?.threadId).toBeUndefined(); + processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + interpret: async input => ({ + ...unlinked(), + events: [opened(input.message)], + analysis: { ...unlinked().analysis, status: "applied" }, + }), + }); + await mirrorCard(context.plan, processor); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS); + expect(context.plan.conversationPlan.events.at(-1)?.id).toBe( + action === "submit" ? `card:${first.id}:decided:1` : `card:${first.id}:discarded`, + ); + await processor.accept({ + id: "m-full", + text: "Can we ship?", + ts: 1001, + author: { kind: "member", handle: "ana" }, + }); + processor.afterMessage(); + await processor.idle(); + expect(context.plan.conversationPlan.queue.at(-1)).toMatchObject({ + messageId: "m-full", + status: "failed", + error: "Conversation history is full", + }); + if (action === "submit") { + await select(context, ws, unlinkedCard.id); + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: "unlinked", + id: unlinkedCard.id, + revision: Store.get(context.plan.questions, unlinkedCard.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "unlinked", + id: unlinkedCard.id, + }); + } + expect(context.plan.records.get(unlinkedCard.id)?.status).toBe( + action === "submit" ? "answered" : "cancelled", + ); + expect(context.plan.pendingCardActions).toEqual([]); + } finally { + processor?.stop(); + await processor?.idle(); + await Plan.close(context.plan); + } + }, 20_000); +} + +test("ordinary asks and startup backfill preserve reserved mirror slots", async () => { + let context = await openPlan(); + try { + let first = await ask(context); + let { ws } = member(context); + fill(context, 3); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: first.id, + }); + let ordinary = await ask(context); + expect(ordinary.threadId).toBeUndefined(); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 3); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.get(ordinary.id)?.threadId).toBeUndefined(); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 3); + expect(context.plan.pendingCardActions).toHaveLength(1); + } finally { + await Plan.close(context.plan); + } +}, 20_000); diff --git a/apps/server/src/questions/conversation-card-1.test.ts b/apps/server/src/questions/conversation-card-1.test.ts new file mode 100644 index 00000000..ba728691 --- /dev/null +++ b/apps/server/src/questions/conversation-card-1.test.ts @@ -0,0 +1,201 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as Jobs from "../conversation-plan/jobs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input, quotedOption } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a pending conversation card without options appends once and survives restart", async () => { + let context = await openPlan("Opening paragraph.\n"); + plans.push(context.plan); + let [id, repeated] = await Promise.all([ + Questions.insertConversationCard(context.plan, context.server, "test", input()), + Questions.insertConversationCard(context.plan, context.server, "test", input()), + ]); + expect(repeated).toBe(id); + expect(context.plan.records.size).toBe(1); + expect(context.plan.records.get(id)).toMatchObject({ + origin: "conversation", + threadId: "thread-a", + status: "open", + editors: [], + }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.settle).toBeUndefined(); + let source = room.project(context.plan.document); + expect(source.indexOf("Opening paragraph.")).toBeLessThan(source.indexOf(" { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let record = context.plan.records.get(id)!; + expect(Questions.meta(context.plan, record).refining).toBe(false); + context.plan.conversationPlanJobs = Jobs.enqueue([], { + kind: "refine", + target: id, + trigger: "m1", + }, "2026-09-25T10:00:00.000Z"); + expect(Questions.meta(context.plan, record).refining).toBe(true); + context.plan.conversationPlanJobs = Jobs.start( + context.plan.conversationPlanJobs, + `refine:${id}:m1`, + "2026-09-25T10:00:01.000Z", + ); + expect(Questions.meta(context.plan, record).refining).toBe(true); + context.plan.conversationPlanJobs = Jobs.settle( + context.plan.conversationPlanJobs, + `refine:${id}:m1`, + { status: "done", output: "{}" }, + "2026-09-25T10:00:02.000Z", + ); + expect(Questions.meta(context.plan, record).refining).toBe(false); +}); + +test("a conversation card can be discarded before anyone adds an option", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.discard(context.plan, context.server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:discard", ok: true }); + expect(context.plan.records.get(id)?.status).toBe("discarded"); + expect(room.project(context.plan.document)).toContain('status="discarded"'); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)?.status).toBe("discarded"); + expect(restored.records.get(id)?.definition.questions[0]?.options).toEqual([]); + expect(room.project(restored.document)).toContain('status="discarded"'); +}); + +test("the first quoted option keeps its ID, persists provenance, and makes the card suggestible", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let before = context.broadcasts.length; + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([{ + id: OPTION, + label: "GitHub Apps", + description: "", + }]); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ origin: "chat" }); + expect(context.broadcasts.slice(before).filter(frame => frame.kind === "question:changed")) + .toHaveLength(1); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect( + Question.read( + Store.get(context.plan.questions, id)!.model, + Store.get(context.plan.questions, id)!.definition, + )[context.plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBeNull(); + expect(Store.get(context.plan.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 2, + }); +}); + +test("a quoted option relabel commits card and event together, and survives restart", async () => { + let { context, id, state } = await quotedOption(); + let revision = Store.get(context.plan.questions, id)!.revision; + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: revision, + }; + let result = await Questions.relabelConversationOption( + context.plan, + context.server, + "test", + request, + ); + expect(result.eventId).toBe("option-relabel:shorten-github"); + expect(context.plan.conversationPlan.threads[0]!.contributions[0]).toMatchObject({ + id: OPTION, + text: "We should use GitHub Apps for auth", + displayLabel: "GitHub Apps", + sources: [{ quote: "We should use GitHub Apps for auth", start: 0, end: 34 }], + }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]).toMatchObject({ + id: OPTION, + label: "GitHub Apps", + }); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ origin: "chat" }); + expect(room.project(context.plan.document)).toContain('label="GitHub Apps"'); + let broadcastCount = context.broadcasts.length; + expect(await Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .toEqual({ eventId: result.eventId, revision: result.revision }); + expect(context.broadcasts).toHaveLength(broadcastCount); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.conversationPlan.threads[0]!.contributions[0]!.displayLabel).toBe("GitHub Apps"); + expect(restored.records.get(id)!.definition.questions[0]!.options[0]!.label).toBe("GitHub Apps"); +}); diff --git a/apps/server/src/questions/conversation-card-2.test.ts b/apps/server/src/questions/conversation-card-2.test.ts new file mode 100644 index 00000000..423a9b6b --- /dev/null +++ b/apps/server/src/questions/conversation-card-2.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input, quotedOption } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("relabel refuses stale, grouped, duplicate, and invalid-source requests without a mutation", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + let original = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + for ( + let input of [ + { ...request, expectedThreadVersion: request.expectedThreadVersion - 1 }, + { ...request, expectedCardRevision: request.expectedCardRevision + 1 }, + { ...request, label: "Use OAuth" }, + { ...request, label: "GitHub Apps or OAuth" }, + { ...request, label: " " }, + ] + ) { + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", input)) + .rejects.toThrow(); + } + let message = context.plan.chat.entries.find(item => item.id === "quoted-option-message")!; + message.text = "Changed after citation"; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("quote does not match"); + message.text = "We should use GitHub Apps for auth"; + expect(context.plan.conversationPlan).toEqual(state); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("We should use GitHub Apps for auth"); + expect(Store.get(context.plan.questions, id)!.revision).toBe(request.expectedCardRevision); + expect(room.project(context.plan.document)).toBe(original); + expect(context.broadcasts).toHaveLength(broadcasts); +}); + +test("relabel storage failure leaves both states unchanged and retry succeeds", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + let source = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.conversationPlan).toEqual(state); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("We should use GitHub Apps for auth"); + expect(Store.get(context.plan.questions, id)!.revision).toBe(request.expectedCardRevision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(broadcasts); + await Questions.relabelConversationOption(context.plan, context.server, "test", request); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("GitHub Apps"); +}); + +test("relabel refuses a claimed implementation and a closed card", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + context.plan.execution = { id: "run-1" } as never; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + context.plan.records.get(id)!.status = "discarded"; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("no longer editable"); + expect(context.plan.conversationPlan).toEqual(state); +}); + +test("a failed insert leaves no live or durable half-card and succeeds on retry", async () => { + let context = await openPlan(); + plans.push(context.plan); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.insertConversationCard(context.plan, context.server, "test", input())) + .rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.size).toBe(0); + expect(context.plan.questions.open.size).toBe(0); + expect(room.project(context.plan.document)).not.toContain(" { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let outcome = await Service.exclusive( + context.plan, + () => + Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "planner", + rationale: "Fits the existing repository permissions.", + }, true), + ); + off(); + expect(outcome).toEqual({ ok: true, optionId: OPTION }); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ + origin: "planner", + rationale: "Fits the existing repository permissions.", + }); + expect(events).toMatchObject([{ kind: "option-added", optionId: OPTION, origin: "planner" }]); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "github apps", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "duplicate" }); +}); + +test("a failed option commit leaves the record, draft, and document unchanged", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + let source = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, id)!.revision; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.revision).toBe(revision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); +}); diff --git a/apps/server/src/questions/conversation-card-3.test.ts b/apps/server/src/questions/conversation-card-3.test.ts new file mode 100644 index 00000000..45f09e50 --- /dev/null +++ b/apps/server/src/questions/conversation-card-3.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("server retitle persists the record, draft, and one card prompt under the held lock", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "GitHub Apps" }]), + ); + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + let before = context.broadcasts.length; + let result = await Service.exclusive( + context.plan, + () => + Questions.retitle(context.plan, context.server, "test", id, "Which auth system first?", true), + ); + expect(result).toMatchObject({ ok: true, revision: 2 }); + expect(context.plan.records.get(id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(context.plan.questions, id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(context.plan.questions, id)?.suggested?.revision).toBe(2); + expect(room.project(context.plan.document)).toContain('prompt="Which auth system first?"'); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "question:changed", + "question:meta", + ]); + let repeatedAt = context.broadcasts.length; + expect( + await Questions.retitle( + context.plan, + context.server, + "test", + id, + " Which auth system first? ", + ), + ).toEqual({ ok: true, revision: 2 }); + expect(context.broadcasts).toHaveLength(repeatedAt); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(restored.questions, id)?.suggested?.revision).toBe(2); + expect(room.project(restored.document)).toContain('prompt="Which auth system first?"'); +}); + +test("a failed retitle commit does not leak a changed record, draft, document, or broadcast", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + let source = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, id)!.revision; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.retitle( + context.plan, + context.server, + "test", + id, + "Which auth system first?", + )).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(Store.get(context.plan.questions, id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(Store.get(context.plan.questions, id)?.revision).toBe(revision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.retitle( + context.plan, + context.server, + "test", + id, + "Which auth system first?", + ), + ).toMatchObject({ ok: true }); +}); + +test("a failed suggestion commit neither pre-selects nor announces until retry", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "GitHub Apps" }, + ]), + ); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + let entry = Store.get(context.plan.questions, id)!; + expect(entry.revision).toBe(0); + expect(entry.suggested).toBeUndefined(); + expect(Question.read(entry.model, entry.definition)[entry.definition.questions[0]!.id]!.choice) + .toBeNull(); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual(["question:meta"]); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + meta: { suggested: { optionId: OPTION, messageIds: ["m1"], revision: 1 } }, + }); + let after = context.broadcasts.length; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect(Store.get(context.plan.questions, id)!.revision).toBe(1); + expect(context.broadcasts).toHaveLength(after); +}); + +test("withdrawal clears an advisory only after commit and leaves the human draft untouched", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "VPS" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m5"], + }), + ).toBe(true); + let before = context.broadcasts.length; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.suggest(context.plan, context.server, "test", id)) + .rejects.toThrow("storage unavailable"); + } finally { + context.storage.collaboration.commit = original; + } + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m5"]); + expect(context.broadcasts).toHaveLength(before); + expect(await Questions.suggest(context.plan, context.server, "test", id)).toBe(true); + expect(await Questions.suggest(context.plan, context.server, "test", id)).toBe(true); + let live = Store.get(context.plan.questions, id)!; + expect(live.suggested).toBeUndefined(); + expect(live.revision).toBe(2); + expect(Question.read(live.model, live.definition)[live.definition.questions[0]!.id]!.choice) + .toBeNull(); + expect(context.plan.records.get(id)?.history).toEqual([]); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual(["question:meta"]); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)?.suggested).toBeUndefined(); + expect(restored.records.get(id)?.history).toEqual([]); +}); diff --git a/apps/server/src/questions/conversation-card-4.test.ts b/apps/server/src/questions/conversation-card-4.test.ts new file mode 100644 index 00000000..c1e01bfd --- /dev/null +++ b/apps/server/src/questions/conversation-card-4.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input } = fixture; + +// Retained scenarios use the current keyed shared-option protocol. +test("server option growth retains the suggestion at its new card revision", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: SECOND }); + expect(Store.get(context.plan.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 2, + }); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + meta: { suggested: { optionId: OPTION, revision: 2 } }, + }); +}); + +test("a human-added option clears an advisory suggestion after commit", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.addOption(context.plan, context.server, "test", ws, { + kind: "question:option", + question: context.plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "add", + id, + label: "GitHub Apps", + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:option", ok: true }); + expect(Store.get(context.plan.questions, id)!.suggested).toBeUndefined(); + expect(context.broadcasts.at(-1)).toMatchObject({ kind: "question:meta" }); + expect(context.broadcasts.at(-1)).not.toHaveProperty("meta.suggested"); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)!.suggested).toBeUndefined(); +}); + +test("a saved suggestion can be explicitly accepted after a durable restart", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 1, + }); + expect( + Question.read( + Store.get(restored.questions, id)!.model, + Store.get(restored.questions, id)!.definition, + )[restored.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBeNull(); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.submit(restored, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:submit", ok: true }); + expect(restored.records.get(id)).toMatchObject({ + status: "answered", + choices: [OPTION], + owner: "ana", + }); + expect(room.project(restored.document)).toContain("Auth0"); +}); diff --git a/apps/server/src/questions/conversation-card-5.test.ts b/apps/server/src/questions/conversation-card-5.test.ts new file mode 100644 index 00000000..e3f34666 --- /dev/null +++ b/apps/server/src/questions/conversation-card-5.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./service"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("rapid Save, Reopen, Save retains each actor and generation without a mirror listener", async () => { + let context = await openPlan("Only paragraph.\n"); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + let replies: Array> = []; + let actor = "ana"; + let ws = { + data: { + get handle() { + return actor; + }, + client: "client", + room: "test", + }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-1", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + let first = context.plan.records.get(id)!; + context.plan.records.set(id, { + ...first, + prose: [{ + ...room.anchorAt(context.plan.document, 0, room.digests(context.plan.document)[0]!), + orphaned: true, + }], + }); + await Service.persist(context.plan); + actor = "ben"; + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(room.questionnaireIndex(context.plan.document, id)).toBe(1); + expect(context.plan.records.get(id)?.prose?.[0]?.orphaned).toBe(true); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m2"], + }), + ).toBe(true); + actor = "cy"; + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-2", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(replies.filter(frame => frame.ok).map(frame => frame.kind)).toEqual([ + "question:submit", + "question:reopen", + "question:submit", + ]); + expect(context.plan.pendingCardActions.map(action => [action.id, action.actor])).toEqual([ + [`card:${id}:decided:1`, "ana"], + [`card:${id}:reopened:1`, "ben"], + [`card:${id}:decided:2`, "cy"], + ]); + expect(context.plan.conversationPlanPendingEffects.filter(item => item.kind === "job")) + .toMatchObject([ + { key: `job:prose:${id}:1`, intent: { kind: "prose", trigger: `decided:${id}:1` } }, + { key: `job:prose:${id}:2`, intent: { kind: "prose", trigger: `decided:${id}:2` } }, + ]); + expect(context.plan.pendingCardActions[0]).toMatchObject({ + threadId: "thread-a", + optionIds: [OPTION], + text: "Auth0", + }); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.pendingCardActions).toEqual(context.plan.pendingCardActions); + expect(restored.conversationPlanPendingEffects).toEqual( + context.plan.conversationPlanPendingEffects, + ); +}); diff --git a/apps/server/src/questions/conversation-card-6.test.ts b/apps/server/src/questions/conversation-card-6.test.ts new file mode 100644 index 00000000..f10dac0e --- /dev/null +++ b/apps/server/src/questions/conversation-card-6.test.ts @@ -0,0 +1,171 @@ +import { expect, spyOn, test } from "bun:test"; + +import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as Questions from "./service"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("Reopen follows moved decision prose in one commit and rolls back on storage failure", async () => { + let context = await openPlan("# Title\n\nDecision prose.\n\nOther.\n"); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(context.plan.records.get(id)?.status).toBe("answered"); + let anchor = room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!); + context.plan.records.set(id, { ...context.plan.records.get(id)!, prose: [anchor] }); + await Service.persist(context.plan); + let vector = Y.encodeStateVector(context.plan.document.doc); + context.plan.document.editor.update(() => { + let blocks = $getRoot().getChildren(); + blocks[3]!.insertAfter(blocks[1]!); + blocks[1]!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + Questions.rebase(context.plan); + anchor = context.plan.records.get(id)!.prose![0]!; + await Service.exclusive(context.plan, () => + Service.publish( + context.plan, + context.server, + "test", + { + update: Y.encodeStateAsUpdate(context.plan.document.doc, vector), + source: room.project(context.plan.document), + }, + )); + let proseAt = room.digests(context.plan.document).findIndex((_, index) => + room.matchesAnchor(context.plan.document, anchor, index) + ); + expect(proseAt).toBeGreaterThan(room.questionnaireIndex(context.plan.document, id)!); + let proseKey = room.resolveAnchor(context.plan.document, anchor); + let proseType = context.plan.document.binding.collabNodeMap.get(proseKey!)?.getSharedType(); + let proseCaret = Y.createRelativePositionFromTypeIndex(proseType!, 0, -1); + let emptyKey: string | undefined; + context.plan.document.editor.getEditorState().read(() => { + emptyKey = $getRoot().getChildren().find(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + )?.getKey(); + }); + if (!emptyKey) throw new Error("missing live empty caret paragraph"); + let emptyType = context.plan.document.binding.collabNodeMap.get(emptyKey)?.getSharedType(); + let emptyCaret = Y.createRelativePositionFromTypeIndex(emptyType!, 0, -1); + let beforeSource = room.project(context.plan.document); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + beforeSource, + [], + ); + let beforeRecord = structuredClone(context.plan.records.get(id)); + let beforeFrames = context.broadcasts.length; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "failed-reopen", + id, + }); + } finally { + errors.mockRestore(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(id)).toEqual(beforeRecord); + expect(Y.createAbsolutePositionFromRelativePosition(emptyCaret, context.plan.document.doc, false)) + .not.toBeNull(); + expect(context.broadcasts.slice(beforeFrames).filter(frame => frame.kind === "plan:update")) + .toHaveLength(0); + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:reopen", ok: true }); + let movedAnchor = context.plan.records.get(id)!.prose![0]!; + let afterProseAt = room.digests(context.plan.document).findIndex((_, index) => + room.matchesAnchor(context.plan.document, movedAnchor, index) + ); + expect(afterProseAt).toBeGreaterThanOrEqual(0); + expect(room.questionnaireIndex(context.plan.document, id)).toBe(afterProseAt + 1); + expect(room.resolveAnchor(context.plan.document, context.plan.records.get(id)!.prose![0]!)) + .toBe(proseKey); + expect(Y.createAbsolutePositionFromRelativePosition(proseCaret, context.plan.document.doc, false)) + .not.toBeNull(); + context.plan.document.editor.getEditorState().read(() => { + expect($getRoot().getChildren().some(node => node.getKey() === emptyKey)).toBe(true); + }); + expect(Y.createAbsolutePositionFromRelativePosition(emptyCaret, context.plan.document.doc, false)) + .not.toBeNull(); + expect(context.plan.records.get(id)).toMatchObject({ + status: "reopened", + history: [{ choices: [OPTION], owner: "ana" }], + }); + expect(room.project(context.plan.document)).toContain(" + frame.kind === "plan:update" + ); + expect(updates).toHaveLength(1); + let update = updates[0]?.update; + if (typeof update !== "string") throw new Error("missing persisted Reopen delta"); + Y.applyUpdate(peer.doc, Buffer.from(update, "base64"), "remote"); + await room.settle(); + expect(Y.createAbsolutePositionFromRelativePosition(proseCaret, peer.doc, false)) + .not.toBeNull(); + let peerCaret = Y.createAbsolutePositionFromRelativePosition(emptyCaret, peer.doc, false); + expect(peerCaret).not.toBeNull(); + peer.editor.getEditorState().read(() => { + expect( + $getRoot().getChildren().some(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + && peer.binding.collabNodeMap.get(node.getKey())?.getSharedType() === peerCaret?.type + ), + ).toBe(true); + }); + expect(room.project(peer)).toBe(room.project(context.plan.document)); + peer.doc.destroy(); +}); diff --git a/apps/server/src/questions/conversation-card-7.test.ts b/apps/server/src/questions/conversation-card-7.test.ts new file mode 100644 index 00000000..1e792fd6 --- /dev/null +++ b/apps/server/src/questions/conversation-card-7.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./service"; +import * as Store from "./store"; +import { mirrorCard } from "../conversation-plan/cards"; +import { initialState } from "../conversation-plan/domain"; +import { applyEvent } from "../conversation-plan/events"; + +import { createProcessor } from "../conversation-plan/service"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a long custom Save keeps its full answer and mirrors after a durable restart", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "GitHub Apps" }]), + ); + let question = "What auth system should we use?"; + context.plan.chat.entries.push({ + id: "m-question", + author: { kind: "member", handle: "ana" }, + text: question, + ts: 1, + }); + context.plan.conversationPlan = applyEvent(initialState(), { + id: "m-question:0:thread.opened:v1", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: { + messageId: "m-question", + author: { kind: "member", handle: "ana" }, + quote: question, + start: 0, + end: question.length, + role: "question", + }, + question, + }); + let answer = "A".repeat(600); + let entry = Store.get(context.plan.questions, id)!; + let questionId = entry.definition.questions[0]!.id; + let human = entry.model.fork(); + human.api.val([questionId, "mode"]).set("custom"); + human.api.str([questionId, "custom"]).ins(0, answer); + let patch = human.api.flush(); + if (!patch) throw new Error("custom answer made no patch"); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.edit(context.plan, ws, { + kind: "question:edit", + ts: 0, + rid: "edit", + id, + patch: [...patch.toBinary()], + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:edit", accepted: true }); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(context.plan.questions, id)!.revision, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:submit", ok: true }); + expect(context.plan.records.get(id)?.answers?.[questionId]).toBe(answer); + expect(context.plan.pendingCardActions).toMatchObject([{ + kind: "decided", + text: `${"A".repeat(499)}…`, + }]); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)?.answers?.[questionId]).toBe(answer); + let processor = createProcessor({ + plan: restored, + exclusive: action => Service.exclusive(restored, action), + persist: () => Service.persistExclusive(restored), + publish: () => {}, + active: () => true, + }); + try { + await mirrorCard(restored, processor); + expect(restored.pendingCardActions).toEqual([]); + expect(restored.conversationPlan.threads[0]?.decisionHistory[0]?.text.length) + .toBeLessThanOrEqual(500); + } finally { + processor.stop(); + } +}); + +test("failed card Save does not enqueue a half-committed mirror action", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + let original = context.storage.collaboration.commit; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send() {}, + publish() {}, + } as unknown as Socket; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(id)!.status).toBe("open"); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlanPendingEffects).toEqual([]); +}); + +test("a full prose outbox refuses Save before any answered record or node is published", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + context.plan.conversationPlanPendingEffects = Array.from({ length: 1024 }, (_, index) => ({ + key: `job:heading:document:${index}`, + kind: "job" as const, + intent: { kind: "heading" as const, target: "document", trigger: `m${index}` }, + })); + let before = room.project(context.plan.document); + let beforeFrames = context.broadcasts.length; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(frames.some(frame => frame.kind === "question:submit" && frame.ok === true)).toBe(false); + expect(context.plan.records.get(id)?.status).toBe("open"); + expect(Store.get(context.plan.questions, id)?.claim).toBeUndefined(); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlanPendingEffects).toHaveLength(1024); + expect(context.broadcasts).toHaveLength(beforeFrames); +}); diff --git a/apps/server/src/questions/conversation-card-8.test.ts b/apps/server/src/questions/conversation-card-8.test.ts new file mode 100644 index 00000000..18df8415 --- /dev/null +++ b/apps/server/src/questions/conversation-card-8.test.ts @@ -0,0 +1,207 @@ +import { expect, spyOn, test } from "bun:test"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input, source } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a committed Save still wakes its durable prose outbox if its socket closes", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + let wakes = 0; + let unlisten = Questions.listen(context.plan, event => { + if (event.kind === "decided") wakes++; + }); + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send() { + throw new Error("socket closed"); + }, + publish() {}, + } as unknown as Socket; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + } finally { + errors.mockRestore(); + unlisten(); + } + expect(context.plan.records.get(id)?.status).toBe("answered"); + expect(context.plan.conversationPlanPendingEffects).toMatchObject([{ + key: `job:prose:${id}:1`, + }]); + expect(wakes).toBe(1); +}); + +test("only the winning concurrent Save captures a prose job claimant", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + let captured: string[] = []; + let person = (handle: string) => + ({ + data: { handle, client: `client-${handle}`, room: "test" }, + send() {}, + publish() {}, + }) as unknown as Socket; + let ask = { + kind: "question:submit" as const, + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }; + await Promise.all([ + Questions.submit( + context.plan, + context.server, + "test", + person("ana"), + ask, + intent => captured.push(`ana:${intent.trigger}`), + ), + Questions.submit( + context.plan, + context.server, + "test", + person("ben"), + ask, + intent => captured.push(`ben:${intent.trigger}`), + ), + ]); + expect(captured).toHaveLength(1); + expect(captured[0]).toContain(`decided:${id}:1`); + expect(captured[0]?.startsWith(context.plan.records.get(id)?.owner ?? "")).toBe(true); +}); + +test("closed cards and an active implementation refuse server-authored changes", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "GitHub Apps" }, + ]), + ); + context.plan.execution = { id: "run-1" } as never; + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "Auth0", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "closed" }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(false); + await expect(Questions.insertConversationCard(context.plan, context.server, "test", { + ...input(), + threadId: "thread-b", + })).rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + context.plan.records.set(id, { ...context.plan.records.get(id)!, status: "discarded" }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "Auth0", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "closed" }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(false); +}); + +test("involved puts the owner first, then card editors and thread participants", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + context.plan.conversationPlan.threads.push({ + id: "thread-a", + question: "What auth system should we use?", + questionSources: [source("opening")], + questionAuthoring: "quoted", + status: "exploring", + contributions: [{ + id: OPTION, + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [source("option")], + actor: { kind: "classifier" }, + }], + stances: [{ + id: "stance", + participant: "participant", + position: "support", + sources: [source("stance")], + at: 1, + }], + stanceHistory: [], + decisionHistory: [], + candidates: [], + questionnaireId: id, + version: 1, + }); + let record = context.plan.records.get(id)!; + context.plan.records.set(id, { ...record, owner: "owner", editors: ["editor"] }); + Store.get(context.plan.questions, id)!.editors.add("live"); + expect(Questions.involved(context.plan, context.plan.records.get(id)!)).toEqual([ + "owner", + "editor", + "live", + "opening", + "option", + "stance", + "participant", + ]); +}); diff --git a/apps/server/src/questions/conversation-card.test-fixtures.ts b/apps/server/src/questions/conversation-card.test-fixtures.ts new file mode 100644 index 00000000..e7e2ed2c --- /dev/null +++ b/apps/server/src/questions/conversation-card.test-fixtures.ts @@ -0,0 +1,130 @@ +import { afterEach } from "bun:test"; +import * as Questions from "./service"; +import { initialState } from "../conversation-plan/domain"; +import { applyEvent } from "../conversation-plan/events"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; + +function input(options: Array<{ id: string; label: string }> = []) { + return { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }; +} + +function source(handle: string): ConversationPlan.SourceRef { + return { + messageId: `message-${handle}`, + author: { kind: "member", handle }, + quote: "Auth", + start: 0, + end: 4, + role: "question", + }; +} + +// Exact archive helpers/callback; synchronous per-suite lexical binding wrapper. +export function createConversationCardFixture( + readPlans: () => Plan[], + writePlans: (value: Plan[]) => void, +) { + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const SECOND = "01K0N4W3B7P27CBAEC7A8C8WEB"; + + let plans: Plan[] = []; + + async function quotedOption() { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "We should use GitHub Apps for auth" }, + { id: SECOND, label: "Use OAuth" }, + ]), + ); + let message = { + id: "quoted-option-message", + author: { kind: "member" as const, handle: "ana" }, + text: "We should use GitHub Apps for auth", + ts: 1, + }; + context.plan.chat.entries.push(message); + let state = applyEvent(initialState(), { + id: "opened", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "What auth system should we use?", + }); + state = applyEvent(state, { + id: "quoted", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: { + messageId: message.id, + author: message.author, + quote: message.text, + start: 0, + end: message.text.length, + role: "option", + }, + contribution: { + id: OPTION, + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }); + state = applyEvent(state, { + id: "second", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 3, + contribution: { id: SECOND, text: "Use OAuth", authoring: "scribe", targetId: "thread-a" }, + }); + state = applyEvent(state, { + id: "linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + questionnaireId: id, + }); + context.plan.conversationPlan = state; + return { context, id, state }; + } + + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { OPTION, SECOND, plans, input, source, quotedOption }; +} diff --git a/apps/server/src/questions/decision-anchor-lifecycle.test.ts b/apps/server/src/questions/decision-anchor-lifecycle.test.ts new file mode 100644 index 00000000..0d44f6cb --- /dev/null +++ b/apps/server/src/questions/decision-anchor-lifecycle.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "./service"; +import { createDecisionProseFixture } from "../agent/decision-prose.test-fixtures"; + +function assertAt( + document: room.Document, + anchor: ReturnType, + index: number, +) { + expect(anchor.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, anchor, index)).toBe(true); + expect(anchor.digest).toBe(room.digests(document)[index]); +} + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => contexts = value); +contexts = fixture.contexts; + +test("two staged decision prose jobs keep separate anchors through replacement and Planner append", async () => { + let { CARD, OTHER_WIDGET, WIDGET, opened, job, proseTool, input } = fixture; + let second = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; + let context = await opened(`Context.\n\n${WIDGET}\n${OTHER_WIDGET}`); + let original = context.plan.records.get(CARD)!; + context.plan.records.set( + second, + Questions.normalizeRecord({ + ...original, + id: second, + threadId: "t2", + definition: { + questions: original.definition.questions.map(question => ({ + ...question, + id: "01K0N4Y2M5R8T3VQ7YB6ZC4DEF", + options: question.options.map(option => ({ + ...option, + id: "01K0N4Z2M5R8T3VQ7YB6ZC4DEF", + })), + })), + }, + choices: ["01K0N4Z2M5R8T3VQ7YB6ZC4DEF"], + }), + ); + let tool = proseTool(context); + let check = (first: number, secondIndex?: number) => { + assertAt(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, first); + if (secondIndex !== undefined) { + assertAt(context.plan.document, context.plan.records.get(second)!.prose![0]!, secondIndex); + } + }; + job(context); + expect( + JSON.parse(String(await tool.handler(input(context, "Sentry monitoring."), {} as never))).ok, + ).toBe(true); + check(1); + context.plan.chat.job = { + id: `prose:${second}:decided:${second}:1`, + kind: "prose", + target: second, + trigger: `decided:${second}:1`, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + expect( + JSON.parse( + String( + await tool.handler( + { revision: context.plan.revision, id: second, text: "Slack alerts." }, + {} as never, + ), + ), + ).ok, + ).toBe(true); + check(1, 3); + job(context); + expect( + JSON.parse(String(await tool.handler(input(context, "Grafana monitoring."), {} as never))).ok, + ).toBe(true); + check(1, 3); + let before = room.project(context.plan.document); + let outcome = edit.apply(context.plan, context.plan.revision, [{ + op: "insert", + index: room.digests(context.plan.document).length - 1, + source: "- Browser one\n- Browser two\n- Browser three\n", + }]); + expect(outcome.ok).toBe(true); + Questions.rebase(context.plan, before); + check(1, 3); + await Service.persist(context.plan); + let reopened = await Service.open(context.channel.id, context.backend, context.server); + try { + assertAt(reopened.document, reopened.records.get(CARD)!.prose![0]!, 1); + assertAt(reopened.document, reopened.records.get(second)!.prose![0]!, 3); + } finally { + await Service.close(reopened); + } +}); diff --git a/apps/server/src/questions/option-match.ts b/apps/server/src/questions/option-match.ts new file mode 100644 index 00000000..5dacb534 --- /dev/null +++ b/apps/server/src/questions/option-match.ts @@ -0,0 +1,45 @@ +function concepts(label: string): string { + let words: string[] = label.normalize("NFKC").toLocaleLowerCase().match(/[a-z0-9]+/g) ?? []; + let action = ["use", "using", "choose"].includes(words[0] ?? ""); + if (action) words.shift(); + if (action && words.includes("for")) words = words.slice(0, words.indexOf("for")); + let normalized = words.map(word => + word.length >= 4 && word.endsWith("s") && !word.endsWith("ss") + ? word.slice(0, -1) + : word + ).filter(word => !["a", "an", "the", "and"].includes(word)); + // Browser-native can describe an API family; Native can also be part of a product name. + if ( + normalized.includes("api") + && normalized.filter(word => word !== "browser" && word !== "native").length >= 3 + ) { + normalized = normalized.filter(word => word !== "browser" && word !== "native"); + } + return normalized.join(" "); +} + +export function grouped(label: string): boolean { + let slashParts = label.split(/(?<=[a-z0-9])\s*\/\s*(?=[a-z0-9])/i); + return /\b(?:or|versus|vs\.?)\b/i.test(label) + || /,[^,]+,/.test(label) + || /,[^,]+\band\b/i.test(label) + || slashParts.length >= 3 + || (slashParts.length === 2 && /\s\/|\/\s/.test(label)); +} + +export function sameApproach(left: string, right: string): boolean { + let a = concepts(left); + let b = concepts(right); + if (!a || !b) { + return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase(); + } + return a === b; +} + +export function atomicOptionQuote(quote: string, label: string): boolean { + if (grouped(quote)) return false; + if (sameApproach(quote, label)) return true; + let words = quote.normalize("NFKC").toLocaleLowerCase().match(/[a-z0-9]+/g) ?? []; + return words[0] === "just" && words[1] === "native" && words.length >= 4 + && sameApproach(words.slice(1).join(" "), label); +} diff --git a/apps/server/src/questions/planner-lifecycle-durability.test.ts b/apps/server/src/questions/planner-lifecycle-durability.test.ts new file mode 100644 index 00000000..c1436c96 --- /dev/null +++ b/apps/server/src/questions/planner-lifecycle-durability.test.ts @@ -0,0 +1,242 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Questions from "./service"; +import * as Store from "./store"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlan } from "../testing/plan"; +import { createProcessor } from "../conversation-plan/service"; +import { mirrorCard } from "../conversation-plan/card-mirror-runner"; +import { replay } from "../conversation-plan/domain"; +import { MAX_EVENTS } from "../conversation-plan/validation"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Socket } from "../wire"; + +function definition() { + return Questions.identify({ + questions: [{ + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [{ label: "Managed service", description: "" }], + }], + }); +} + +async function ask(context: Awaited>) { + let created = Promise.withResolvers(); + let waiting = Questions.ask( + context.plan, + context.server, + context.plan.id, + definition(), + undefined, + created.resolve, + ); + await Promise.race([created.promise, waiting]); + let record = [...context.plan.records.values()].at(-1)!; + return { record, waiting }; +} + +function member(context: Awaited>) { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: context.plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +test("ordinary Planner asks remain available after conversation thread capacity", async () => { + let context = await openPlan(); + try { + for (let index = 0; index < 21; index++) await ask(context); + expect(context.plan.records.size).toBe(21); + expect(context.plan.questions.open.size).toBe(21); + expect(context.plan.conversationPlan.threads).toHaveLength(20); + let last = [...context.plan.records.values()].at(-1)!; + expect(last.threadId).toBeUndefined(); + expect(Room.questionnaireProjections(context.plan.document).find(card => card.id === last.id)) + .not.toHaveProperty("thread"); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.size).toBe(21); + expect(context.plan.questions.open.size).toBe(21); + } finally { + await Plan.close(context.plan); + } +}); + +test("failed Planner ask commits leave no live or durable card or thread", async () => { + let context = await openPlan(); + let commit = spyOn(context.storage.collaboration, "commit") + .mockRejectedValueOnce(new Error("storage unavailable")); + try { + await expect(Questions.ask(context.plan, context.server, context.plan.id, definition())) + .rejects.toThrow("storage unavailable"); + expect(context.plan.records.size).toBe(0); + expect(context.plan.questions.open.size).toBe(0); + expect(context.plan.conversationPlan.threads).toEqual([]); + expect(Room.project(context.plan.document)).toBe(""); + expect(context.broadcasts).toEqual([]); + let saved = await context.storage.collaboration.load(context.plan.id, new Date()); + expect(saved?.sidecar ?? saved?.snapshot?.sidecar) + .toMatchObject({ questions: [], openQuestions: [] }); + } finally { + commit.mockRestore(); + await Plan.close(context.plan); + } +}); + +test("ordinary Planner asks remain available after conversation event capacity", async () => { + let context = await openPlan(); + try { + await ask(context); + let thread = context.plan.conversationPlan.threads[0]!; + let remaining = MAX_EVENTS - context.plan.conversationPlan.events.length; + let events: ConversationPlan.Event[] = Array.from({ length: remaining }, (_, index) => ({ + id: `discard-history:${index}`, + type: "thread.discarded", + threadId: thread.id, + observedThreadVersion: thread.version + index, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: index, + })); + context.plan.conversationPlan = replay([...context.plan.conversationPlan.events, ...events]); + let state = context.plan.conversationPlan; + let { record } = await ask(context); + expect(record.threadId).toBeUndefined(); + expect(context.plan.conversationPlan).toBe(state); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS); + expect(context.plan.records.size).toBe(2); + expect(context.plan.questions.open.size).toBe(2); + } finally { + await Plan.close(context.plan); + } +}, 20_000); + +test("cancellation keeps a linked draft private until commit and mirrors discard after restart", async () => { + let context = await openPlan(); + let { record, waiting } = await ask(context); + let { ws, frames } = member(context); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commit = context.storage.collaboration.commit.bind(context.storage.collaboration); + let held = spyOn(context.storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commit(input); + }); + let before = Room.project(context.plan.document); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let cancelled = Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: record.id, + }); + try { + await entered.promise; + expect(context.plan.records.get(record.id)?.status).toBe("open"); + expect(Store.snapshot(context.plan.questions, record.id).open).toBe(true); + expect(Room.project(context.plan.document)).toBe(before); + expect(context.plan.pendingCardActions).toEqual([]); + expect(events).toEqual([]); + expect(frames).toEqual([]); + release.resolve(); + await cancelled; + expect(await waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(context.plan.pendingCardActions).toMatchObject([{ + kind: "discarded", + cardId: record.id, + threadId: record.threadId, + actor: "ana", + }]); + expect(events).toMatchObject([{ kind: "discarded", id: record.id }]); + held.mockRestore(); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + let processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + }); + try { + await mirrorCard(context.plan, processor); + await mirrorCard(context.plan, processor); + expect(context.plan.pendingCardActions).toEqual([]); + expect( + context.plan.conversationPlan.threads.find(thread => thread.id === record.threadId) + ?.status, + ).toBe("discarded"); + expect( + context.plan.conversationPlan.events.filter(event => event.type === "thread.discarded"), + ) + .toHaveLength(1); + } finally { + processor.stop(); + await processor.idle(); + } + } finally { + release.resolve(); + await cancelled; + held.mockRestore(); + off(); + await Plan.close(context.plan); + } +}); + +test("failed cancellation releases its claim and preserves the accepted draft", async () => { + let context = await openPlan(); + let { record, waiting } = await ask(context); + let { ws, frames } = member(context); + let entry = Store.get(context.plan.questions, record.id)!; + let model = entry.model.fork(); + let question = entry.definition.questions[0]!; + model.api.val([question.id, "choice"]).set(question.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + await Questions.edit(context.plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id: record.id, + patch: [...patch.toBinary()], + }); + let draft = Store.snapshot(context.plan.questions, record.id); + frames.length = 0; + let source = Room.project(context.plan.document); + let commit = spyOn(context.storage.collaboration, "commit") + .mockRejectedValueOnce(new Error("storage unavailable")); + try { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "failed-cancel", + id: record.id, + }); + expect(context.plan.records.get(record.id)?.status).toBe("open"); + expect(Store.get(context.plan.questions, record.id)?.claim).toBeUndefined(); + expect(Store.snapshot(context.plan.questions, record.id)).toEqual(draft); + expect(Room.project(context.plan.document)).toBe(source); + expect(context.plan.pendingCardActions).toEqual([]); + expect(frames).toMatchObject([{ kind: "session:error", rid: "failed-cancel" }]); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "retry-cancel", + id: record.id, + }); + expect(await waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(context.plan.pendingCardActions).toHaveLength(1); + } finally { + commit.mockRestore(); + await Plan.close(context.plan); + } +}); diff --git a/apps/server/src/questions/prose-1.test.ts b/apps/server/src/questions/prose-1.test.ts new file mode 100644 index 00000000..c19961a7 --- /dev/null +++ b/apps/server/src/questions/prose-1.test.ts @@ -0,0 +1,211 @@ +import { expect, it } from "bun:test"; +import { $createParagraphNode, $createTextNode, $getRoot, $isParagraphNode } from "lexical"; + +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; + +import * as Prose from "./prose"; + +import type { Plan as RoomPlan } from "../plan/service"; + +import { subject } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("keeps the original Yjs block when a person edits it, even beside identical prose", async () => { + let { document, anchor } = await subject( + "# Title\n\nDecision prose.\n\nDecision prose.\n", + ); + let original = room.resolveAnchor(document, anchor); + try { + document.editor.update(() => { + let paragraph = $getRoot().getChildren()[2]; + if (!$isParagraphNode(paragraph)) throw new Error("missing paragraph"); + paragraph.append($createTextNode(" People may add organisations later.")); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.resolveAnchor(document, carried!)).toBe(original); + expect(carried?.digest).toBe(room.digests(document)[2]); + expect(carried?.digest).not.toBe(room.digests(document)[1]); + } finally { + document.doc.destroy(); + } +}); + +it("follows a moved paragraph when its old Yjs position is gone", async () => { + let { document, anchor } = await subject(); + try { + let plan = { document, revision: 1, outlines: new Map() } as RoomPlan; + edit.apply(plan, 1, [{ op: "move", index: 2, to: 1 }]); + expect(room.resolveAnchor(document, anchor)).toBeUndefined(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, carried!, 1)).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("keeps a human-moved paragraph at its new position", async () => { + let { document, anchor } = await subject(); + try { + document.editor.update(() => { + let children = $getRoot().getChildren(); + children[1]!.insertBefore(children[2]!); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, carried!, 1)).toBe(true); + expect(carried?.digest).toBe(room.digests(document)[1]); + } finally { + document.doc.destroy(); + } +}); + +it("orphans a deleted paragraph instead of finding a different block", async () => { + let { document, anchor } = await subject(); + try { + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBe(true); + expect(Prose.orphaned(carried ? [carried] : [])).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("does not retarget a deleted paragraph to its identical surviving neighbour", async () => { + let { document, anchor } = await subject("# Title\n\nSame.\n\nSame.\n"); + let previous = room.project(document); + try { + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor], previous); + expect(room.project(document)).toBe("# Title\n\nSame.\n"); + expect(carried?.orphaned).toBe(true); + expect(room.resolveAnchor(document, carried!)).toBeUndefined(); + } finally { + document.doc.destroy(); + } +}); + +it("recovers a unique paragraph pasted in the next document edit", async () => { + let { document, anchor } = await subject(); + try { + let beforeCut = room.project(document); + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + let [cut] = Prose.carry(document, [anchor], beforeCut); + expect(cut).toMatchObject({ orphaned: true, recoverOnNextEdit: true }); + + let beforePaste = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + }, { discrete: true }); + await room.settle(); + let [pasted] = Prose.carry(document, [cut!], beforePaste); + expect(pasted?.orphaned).toBeUndefined(); + expect(pasted?.recoverOnNextEdit).toBeUndefined(); + expect(room.matchesAnchor(document, pasted!, 2)).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("does not recover after an unrelated edit or from an originally duplicate block", async () => { + let { document, anchor } = await subject(); + try { + let beforeCut = room.project(document); + document.editor.update(() => $getRoot().getChildren()[2]!.remove(), { discrete: true }); + await room.settle(); + let [cut] = Prose.carry(document, [anchor], beforeCut); + let beforeUnrelated = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Unrelated."))); + }, { discrete: true }); + await room.settle(); + let [expired] = Prose.carry(document, [cut!], beforeUnrelated); + expect(expired?.recoverOnNextEdit).toBeUndefined(); + let beforeLater = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + }, { discrete: true }); + await room.settle(); + let [later] = Prose.carry(document, [expired!], beforeLater); + expect(later?.orphaned).toBe(true); + } finally { + document.doc.destroy(); + } + + let duplicate = await subject("# Title\n\nDecision prose.\n\nDecision prose.\n"); + try { + let beforeCut = room.project(duplicate.document); + duplicate.document.editor.update(() => $getRoot().getChildren()[2]!.remove(), { + discrete: true, + }); + await room.settle(); + let [cut] = Prose.carry(duplicate.document, [duplicate.anchor], beforeCut); + expect(cut?.orphaned).toBe(true); + expect(cut?.recoverOnNextEdit).toBeUndefined(); + } finally { + duplicate.document.doc.destroy(); + } +}); + +it("does not leave recovery open when the current prose is ambiguous", async () => { + let { document, anchor } = await subject(); + try { + let previous = room.project(document); + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + for (let index = 0; index < 2; index++) { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + } + }, { discrete: true }); + await room.settle(); + let [ambiguous] = Prose.carry(document, [anchor], previous); + expect(ambiguous?.orphaned).toBe(true); + expect(ambiguous?.recoverOnNextEdit).toBeUndefined(); + let beforeDelete = room.project(document); + document.editor.update(() => $getRoot().getChildren()[3]!.remove(), { + discrete: true, + }); + await room.settle(); + let [remaining] = Prose.carry(document, [ambiguous!], beforeDelete); + expect(remaining?.orphaned).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("orphans an unresolved position when the digest matches two blocks", async () => { + let { document, anchor } = await subject(); + try { + let rotated = await room.replace( + "# Title\n\nDecision prose.\n\nFirst.\n\nDecision prose.\n", + ); + try { + let [carried] = Prose.carry(rotated, [anchor]); + expect(carried?.orphaned).toBe(true); + expect(room.resolveAnchor(rotated, carried!)).toBeUndefined(); + } finally { + rotated.doc.destroy(); + } + } finally { + document.doc.destroy(); + } +}); diff --git a/apps/server/src/questions/prose-2.test.ts b/apps/server/src/questions/prose-2.test.ts new file mode 100644 index 00000000..87382ec1 --- /dev/null +++ b/apps/server/src/questions/prose-2.test.ts @@ -0,0 +1,179 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import * as Y from "yjs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; + +import * as Prose from "./prose"; +import { normalizeRecord } from "./records"; +import * as Questions from "./service"; +import type { Plan } from "@chopin/protocol"; +import type { Plan as RoomPlan } from "../plan/service"; + +import { cardWithProse, peerFrames, subject } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("keeps distinct identical paragraphs across a same-history epoch rebuild", async () => { + let { document, anchor } = await subject("# Title\n\nSame.\n\nSame.\n"); + try { + let rebuilt = await room.rebuild(document); + try { + let [carried] = Prose.carry(rebuilt, [anchor], room.project(document)); + expect(carried?.orphaned).toBeUndefined(); + expect(carried?.epoch).toBe(rebuilt.epoch); + expect(room.matchesAnchor(rebuilt, carried!, 2)).toBe(true); + } finally { + rebuilt.doc.destroy(); + } + } finally { + document.doc.destroy(); + } +}); + +it("does not call absent prose orphaned", () => { + expect(Prose.orphaned(undefined)).toBe(false); + expect(Prose.orphaned([])).toBe(false); + let anchors: Plan.Anchor[] = [{ + epoch: "old", + position: "AA==", + digest: `sha256:${"0".repeat(64)}`, + orphaned: true, + }]; + expect(Prose.orphaned(anchors)).toBe(true); +}); + +it("restores only orphaned prose with a one-edit recovery hint", async () => { + let context = await cardWithProse(); + try { + let record = context.plan.records.get(context.id)!; + let recoverable = { + ...context.anchor, + orphaned: true as const, + recoverOnNextEdit: true as const, + }; + expect(normalizeRecord({ ...record, prose: [recoverable] }).prose).toEqual([recoverable]); + expect(() => + normalizeRecord({ + ...record, + prose: [{ ...context.anchor, recoverOnNextEdit: true }], + }) + ).toThrow(/invalid question record/); + } finally { + await Service.close(context.plan); + } +}); + +it("persists prose and exposes it in open and relationship snapshots", async () => { + let context = await cardWithProse(); + let opened: RoomPlan | undefined = context.plan; + try { + expect(Questions.prose(context.plan)).toEqual([{ + widget: context.id, + anchors: [context.anchor], + orphaned: false, + }]); + expect( + Object.values(Questions.anchors(context.plan)[0]!.questions).every(set => + set.anchors.length === 0 + ), + ).toBe(true); + expect(Questions.meta(context.plan, context.plan.records.get(context.id)!)) + .toMatchObject({ hasProse: true, proseOrphaned: false }); + + let { ws, frames } = peerFrames(); + Service.greet(context.plan, ws, { kind: "plan:open", ts: 0, rid: "open" }); + expect(frames.at(-1)).toMatchObject({ + kind: "plan:open", + prose: [{ widget: context.id, orphaned: false }], + }); + Service.anchors(context.plan, context.server, context.channel.id); + expect(context.broadcasts.at(-2)).toMatchObject({ + kind: "plan:anchors", + prose: [{ widget: context.id, orphaned: false }], + }); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + id: context.id, + meta: { hasProse: true, proseOrphaned: false }, + }); + + await Service.close(opened); + opened = undefined; + let restored = await Service.open(context.channel.id, context.backend, context.server); + opened = restored; + expect(Questions.prose(restored)).toMatchObject([{ + widget: context.id, + orphaned: false, + }]); + expect(room.matchesAnchor(restored.document, restored.records.get(context.id)!.prose![0]!, 1)) + .toBe(true); + } finally { + if (opened) await Service.close(opened); + } +}); + +it("publishes orphaned prose metadata only after a human deletion commits", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + try { + let vector = Y.encodeStateVector(context.plan.document.doc); + peer.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + let update = Y.encodeStateAsUpdate(peer.doc, vector); + let original = context.storage.collaboration.commit; + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await blocked; + return original(input); + }; + let { ws } = peerFrames(); + let before = context.broadcasts.length; + try { + Service.submit(context.plan, ws, { + kind: "plan:update", + ts: 0, + rid: "delete", + id: "delete", + epoch: context.plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + expect( + context.broadcasts.slice(before).filter(frame => + frame.kind === "question:meta" || frame.kind === "plan:anchors" + ), + ).toEqual([]); + } finally { + release!(); + await context.plan.flushing; + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + expect(Questions.meta(context.plan, context.plan.records.get(context.id)!)) + .toMatchObject({ hasProse: false, proseOrphaned: true }); + expect(context.broadcasts.slice(before)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "plan:anchors", + prose: [{ widget: context.id, anchors: expect.any(Array), orphaned: true }], + }), + expect.objectContaining({ + kind: "question:meta", + id: context.id, + meta: expect.objectContaining({ hasProse: false, proseOrphaned: true }), + }), + ])); + } finally { + peer.doc.destroy(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/questions/prose-3.test.ts b/apps/server/src/questions/prose-3.test.ts new file mode 100644 index 00000000..c2fcb6cd --- /dev/null +++ b/apps/server/src/questions/prose-3.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "bun:test"; + +import * as Y from "yjs"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; + +import * as Questions from "./service"; +import type { Plan } from "@chopin/protocol"; +import type { Plan as RoomPlan } from "../plan/service"; + +import { cardWithProse } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("publishes orphaned metadata after a Planner edit's relationship snapshot", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + try { + let outcome = edit.apply(context.plan, context.plan.revision, [{ op: "delete", index: 2 }]); + if (!outcome.ok || !outcome.mutation) throw new Error("expected a paragraph deletion"); + Questions.rebase(context.plan); + expect(Questions.prose(context.plan)[0]?.orphaned).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + let before = context.broadcasts.length; + let original = context.storage.collaboration.commit; + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await blocked; + return original(input); + }; + try { + let publishing = Service.publish( + context.plan, + context.server, + context.channel.id, + outcome.mutation, + ).then(() => Service.anchors(context.plan, context.server, context.channel.id)); + await Bun.sleep(10); + expect(context.broadcasts).toHaveLength(before); + release!(); + await publishing; + } finally { + release!(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "plan:anchors", + "question:meta", + ]); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + id: context.id, + meta: { hasProse: false, proseOrphaned: true }, + }); + } finally { + await Service.close(context.plan); + } +}); + +it("keeps the pre-edit source when a staged document deletes duplicate prose", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + let staged = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + staged.seq = context.plan.document.seq; + try { + let candidate = { + ...context.plan, + document: staged, + records: new Map(context.plan.records), + } as RoomPlan; + let outcome = edit.apply(candidate, candidate.revision, [{ op: "delete", index: 2 }]); + if (!outcome.ok || !outcome.mutation) throw new Error("expected a paragraph deletion"); + Questions.rebase(candidate); + expect(candidate.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + await Service.publishStaged( + context.plan, + context.server, + context.channel.id, + candidate, + outcome.mutation, + ); + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + let persisted = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = persisted?.sidecar as { + questions: Array<{ id: string; prose?: Plan.Anchor[] }>; + } | undefined; + expect(sidecar?.questions.find(item => item.id === context.id)?.prose?.[0]?.orphaned) + .toBe(true); + } finally { + staged.doc.destroy(); + await Service.close(context.plan); + } +}); + +it("uses the pre-rewrite source when replacing a document with duplicate prose", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + try { + let previous = room.project(context.plan.document); + let next = previous.replace("Same.\n\nSame.\n", "Same.\n"); + let outcome = await Service.rewrite(context.plan, next, (source, revision) => ({ + idempotencyKey: "rewrite-prose", + fingerprint: "rewrite-prose", + fromRevision: context.plan.revision, + client: { name: "test", version: "1" }, + document: { source, revision, title: "Title", url: "https://example.test" }, + })); + expect(outcome.ok).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + } finally { + await Service.close(context.plan); + } +}); + +it("rebases a surviving prose anchor across an epoch change", async () => { + let context = await cardWithProse(); + let old = context.plan.document; + try { + let rebuilt = await room.rebuild(old); + context.plan.document = rebuilt; + Questions.rebase(context.plan); + let prose = Questions.prose(context.plan); + expect(prose[0]?.anchors[0]?.epoch).toBe(rebuilt.epoch); + expect(prose[0]?.orphaned).toBe(false); + expect(room.matchesAnchor(rebuilt, prose[0]!.anchors[0]!, 1)).toBe(true); + } finally { + old.doc.destroy(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/questions/prose.test-fixtures.ts b/apps/server/src/questions/prose.test-fixtures.ts new file mode 100644 index 00000000..cae5bd77 --- /dev/null +++ b/apps/server/src/questions/prose.test-fixtures.ts @@ -0,0 +1,50 @@ +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import * as Questions from "./service"; + +import type { Socket } from "../wire"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export async function subject(source = "# Title\n\nFirst.\n\nDecision prose.\n") { + let document = await room.create(source); + let anchor = room.anchorAt(document, 2, room.digests(document)[2]!); + return { document, anchor }; +} + +export async function cardWithProse(source = "# Title\n\nDecision prose.\n", index = 1) { + let context = await openPlan(source); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + context.channel.id, + { + threadId: "thread-a", + header: "Authentication", + question: "Which authentication approach?", + options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "GitHub Apps" }], + }, + ); + let anchor = room.anchorAt( + context.plan.document, + index, + room.digests(context.plan.document)[index]!, + ); + Questions.setProse(context.plan, id, [anchor]); + await Service.persist(context.plan); + return { ...context, id, anchor }; +} + +export function peerFrames() { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test", canEdit: true }, + send(raw: string) { + frames.push(JSON.parse(raw) as Record); + }, + publish() {}, + close() {}, + } as unknown as Socket; + return { ws, frames }; +} diff --git a/apps/server/src/questions/prose.ts b/apps/server/src/questions/prose.ts new file mode 100644 index 00000000..f8b76095 --- /dev/null +++ b/apps/server/src/questions/prose.ts @@ -0,0 +1,60 @@ +/** The paragraph a decided card became, carried without a pending-review state. */ + +import { parse, serialize } from "@chopin/dialect"; +import * as room from "../plan/room"; + +import type { Plan } from "@chopin/protocol"; + +/** + * Prefer the live Yjs block. Durable callers supply the prior source, so a + * lost position may recover by digest only when that digest was unique before + * the edit as well as after it. Otherwise deleting one of two identical + * paragraphs could steal the survivor. Without prior source, retain the + * original two-argument rebasing behavior for direct callers. A unique block + * that disappears has one source-changing edit to reappear; an intervening + * edit from anyone consumes that window and leaves the prose orphaned. + */ +export function carry( + document: room.Document, + anchors: Plan.Anchor[], + previousSource?: string, +): Plan.Anchor[] { + if (anchors.length === 0) return anchors; + let currentSource = previousSource === undefined ? undefined : room.project(document); + let previous = new Map(); + if (previousSource !== undefined) { + for (let node of parse(previousSource).children) { + let hash = room.digest(serialize({ type: "root", children: [node] })); + previous.set(hash, (previous.get(hash) ?? 0) + 1); + } + } + return anchors.map(anchor => { + if (anchor.orphaned) { + if (!anchor.recoverOnNextEdit) return room.rebase(document, [anchor])[0]!; + let { recoverOnNextEdit: _recover, orphaned: _orphaned, ...candidate } = anchor; + if (anchor.epoch !== document.epoch) { + return { ...candidate, epoch: document.epoch, orphaned: true }; + } + if (currentSource === undefined || currentSource === previousSource) return anchor; + return room.rebase(document, [candidate])[0]!; + } + // Rebuilds change the epoch but can retain this exact Yjs history. + let currentEpoch = { ...anchor, epoch: document.epoch }; + let live = room.resolveAnchor(document, currentEpoch); + if (!live && previousSource !== undefined && previous.get(anchor.digest) !== 1) { + return { ...anchor, epoch: document.epoch, orphaned: true }; + } + let rebased = room.rebase(document, [live ? currentEpoch : anchor])[0]!; + return rebased.orphaned && previousSource !== undefined + && previous.get(anchor.digest) === 1 && currentSource !== previousSource + && anchor.epoch === document.epoch + && !room.digests(document).includes(anchor.digest) + ? { ...rebased, recoverOnNextEdit: true } + : rebased; + }); +} + +/** No prose is different from prose whose every block was lost. */ +export function orphaned(anchors: Plan.Anchor[] | undefined): boolean { + return !!anchors?.length && anchors.every(anchor => anchor.orphaned); +} diff --git a/apps/server/src/questions/question-option-source-1.test.ts b/apps/server/src/questions/question-option-source-1.test.ts new file mode 100644 index 00000000..5e57de50 --- /dev/null +++ b/apps/server/src/questions/question-option-source-1.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; +import { applyEvent } from "../conversation-plan/events"; +import { initialState } from "../conversation-plan/domain"; +import { openPlan } from "../testing/plan"; +import { createSourceFixture } from "./question-option-source.test-fixtures"; +let plans: Service.Plan[]; +let fixture = createSourceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION_A, OPTION_B, ref, conversationCard } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("question source matching requires the exact saved mention on the card thread", () => { + let source = ref("message-a", "Which authentication provider?"); + let thread = { + id: "thread-a", + questionSources: [source], + } as ConversationPlan.Thread; + expect(Questions.matchesQuestionSource(source, thread)).toBe(true); + expect(Questions.matchesQuestionSource({ ...source, messageId: "message-b" }, thread)) + .toBe(false); + expect(Questions.matchesQuestionSource({ ...source, quote: "Another question?" }, thread)) + .toBe(false); + expect( + Questions.matchesQuestionSource( + { ...source, author: { kind: "member", handle: "lee" } }, + thread, + ), + ) + .toBe(false); + expect(Questions.matchesQuestionSource({ ...source, role: "reason" }, thread)).toBe(false); + expect(Questions.matchesQuestionSource(source, undefined)).toBe(false); +}); + +test("question mentions can source alternatives, persist exactly, and carry no stance", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + for ( + let [optionId, label] of [ + [OPTION_A, "GitHub Apps"], + [OPTION_B, "OAuth"], + ] as const + ) { + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId, + label, + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: true, optionId }); + } + expect(context.plan.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); + + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(restored.conversationPlan.threads[0]!.stances).toEqual([]); +}); + +test("unrelated, foreign, mismatched, and unsupported sources cannot add options", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + let foreign = ref("message-thread-b", "Which database should we use?"); + context.plan.chat.entries.push({ + id: foreign.messageId, + author: foreign.author, + text: foreign.quote, + ts: 2, + }); + let foreignThread = applyEvent(initialState(), { + id: "thread-b:opened", + type: "thread.opened", + threadId: "thread-b", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: foreign, + question: foreign.quote, + }); + context.plan.conversationPlan = { + ...context.plan.conversationPlan, + revision: context.plan.conversationPlan.revision + foreignThread.revision, + events: [...context.plan.conversationPlan.events, ...foreignThread.events], + threads: [...context.plan.conversationPlan.threads, ...foreignThread.threads], + }; + let substringStart = question.indexOf("GitHub Apps"); + let unrelated = { + ...questionSource, + quote: "GitHub Apps", + start: substringStart, + end: substringStart + "GitHub Apps".length, + }; + let mismatchStart = question.indexOf("OAuth?"); + let mismatchedQuote = { + ...questionSource, + quote: "OAuth?", + start: mismatchStart, + end: mismatchStart + "OAuth?".length, + }; + let invalid = [ + unrelated, + foreign, + mismatchedQuote, + { ...questionSource, role: "support" as const }, + ]; + for (let [index, source] of invalid.entries()) { + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: index === 0 ? OPTION_A : OPTION_B.replace(/.$/, String(index)), + label: `Alternative ${index}`, + origin: "planner", + source, + }), + ).toEqual({ ok: false, reason: "invalid" }); + } + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(id)!.optionOrigins).toEqual({}); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); +}); + +test("a question mention cannot source an unrelated option label", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Redis", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(id)!.optionOrigins).toEqual({}); +}); + +test("the exact D02 hosting question sources both action-prefixed card options", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "hosting for the beta: managed app service or our own server?"; + let { id, questionSource } = await conversationCard(context, "thread-hosting", question); + + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Redis", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Host our own server on a managed app service", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + + let managed = await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Host the beta on a managed app service", + origin: "planner", + source: questionSource, + }); + let ownServer = await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_B, + label: "Host the beta on our own server", + origin: "planner", + source: questionSource, + }); + expect({ managed, ownServer }).toEqual({ + managed: { ok: true, optionId: OPTION_A }, + ownServer: { ok: true, optionId: OPTION_B }, + }); + + expect(context.plan.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); +}); diff --git a/apps/server/src/questions/question-option-source-2.test.ts b/apps/server/src/questions/question-option-source-2.test.ts new file mode 100644 index 00000000..74dd05d8 --- /dev/null +++ b/apps/server/src/questions/question-option-source-2.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; + +import { openPlan } from "../testing/plan"; +import { createSourceFixture } from "./question-option-source.test-fixtures"; +let plans: Service.Plan[]; +let fixture = createSourceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION_A, conversationCard } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("restore rejects a question source saved only on another card's thread", async () => { + let context = await openPlan(); + plans.push(context.plan); + let first = await conversationCard(context, "thread-a", "Should we use GitHub Apps?"); + let second = await conversationCard(context, "thread-b", "Which database?"); + expect( + await Questions.addServerOption(context.plan, context.server, "test", first.id, { + optionId: OPTION_A, + label: "GitHub Apps", + origin: "planner", + source: first.questionSource, + }), + ).toEqual({ ok: true, optionId: OPTION_A }); + await Service.close(context.plan); + plans = []; + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + let questions = sidecar.questions as Array<{ + id: string; + optionOrigins: Record; + }>; + questions.find(record => record.id === first.id)!.optionOrigins[OPTION_A]!.source = + second.questionSource; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "foreign-question-option-source", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)).rejects.toThrow( + "hosted channel has option question source outside its card thread", + ); +}); diff --git a/apps/server/src/questions/question-option-source.test-fixtures.ts b/apps/server/src/questions/question-option-source.test-fixtures.ts new file mode 100644 index 00000000..74133dbf --- /dev/null +++ b/apps/server/src/questions/question-option-source.test-fixtures.ts @@ -0,0 +1,106 @@ +import { afterEach } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; +import { applyEvent } from "../conversation-plan/events"; +import { initialState } from "../conversation-plan/domain"; +import { openPlan } from "../testing/plan"; + +function ref( + messageId: string, + text: string, + role: ConversationPlan.SourceRole = "question", +): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle: "ana" }, + quote: text, + start: 0, + end: text.length, + role, + }; +} + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export function createSourceFixture( + readPlans: () => Service.Plan[], + writePlans: (value: Service.Plan[]) => void, +) { + const OPTION_A = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const OPTION_B = "01K0N4W3B7P27CBAEC7A8C8WEB"; + + let plans: Service.Plan[] = []; + + async function linkQuestion( + context: Awaited>, + id: string, + threadId: string, + questionSource: ConversationPlan.SourceRef, + ) { + let message = { + id: questionSource.messageId, + author: questionSource.author, + text: questionSource.quote, + ts: 1, + }; + context.plan.chat.entries.push(message); + let state = applyEvent(initialState(), { + id: `${threadId}:opened`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: questionSource, + question: questionSource.quote, + }); + state = applyEvent(state, { + id: `${threadId}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: id, + }); + let earlier = context.plan.conversationPlan; + context.plan.conversationPlan = { + ...earlier, + revision: earlier.revision + state.revision, + events: [...earlier.events, ...state.events], + threads: [...earlier.threads, ...state.threads], + }; + } + + async function conversationCard( + context: Awaited>, + threadId: string, + question: string, + ) { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId, + header: "Authentication", + question, + options: [], + }); + let questionSource = ref(`message-${threadId}`, question); + await linkQuestion(context, id, threadId, questionSource); + return { id, questionSource }; + } + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { OPTION_A, OPTION_B, plans, ref, linkQuestion, conversationCard }; +} diff --git a/apps/server/src/questions/record-fields.ts b/apps/server/src/questions/record-fields.ts new file mode 100644 index 00000000..cf601a7d --- /dev/null +++ b/apps/server/src/questions/record-fields.ts @@ -0,0 +1,31 @@ +export const STATUSES = new Set(["open", "answered", "reopened", "discarded", "cancelled"]); +export const ORIGINS = new Set(["chat", "planner", "human"]); +export const MAX_UNIX_SECONDS = 253_402_300_799; + +export type ObjectValue = { [key: string]: unknown }; + +export function invalid(): never { + throw new Error("hosted channel has an invalid question record"); +} + +export function object(value: unknown): ObjectValue { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + return value as ObjectValue; +} + +export function text(value: unknown, max?: number): string { + if (typeof value !== "string" || !value.trim() || max !== undefined && value.length > max) { + invalid(); + } + return value as string; +} + +export function unix(value: unknown): number { + if ( + typeof value !== "number" || !Number.isSafeInteger(value) + || value < 0 || value > MAX_UNIX_SECONDS + ) invalid(); + return value as number; +} + +export type Known = { questions: Map; options: Map }; diff --git a/apps/server/src/questions/record-provenance.ts b/apps/server/src/questions/record-provenance.ts new file mode 100644 index 00000000..b48d8a0f --- /dev/null +++ b/apps/server/src/questions/record-provenance.ts @@ -0,0 +1,45 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +/** A question mention is provenance only when it is the card thread's exact saved ref. */ +export function matchesQuestionSource( + source: ConversationPlan.SourceRef, + thread: ConversationPlan.Thread | undefined, +): boolean { + return source.role === "question" + && !!thread?.questionSources.some(ref => + ref.role === "question" && ref.messageId === source.messageId + && ref.quote === source.quote && ref.start === source.start && ref.end === source.end + && ref.author.kind === source.author.kind + && (ref.author.kind !== "member" || source.author.kind === "member" + && ref.author.handle === source.author.handle) + ); +} + +function words(value: string): string[] { + return value.normalize("NFKC").toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +} + +function containsWords(haystack: string[], part: string[]): boolean { + return part.length > 0 + && haystack.some((_, index) => part.every((word, offset) => haystack[index + offset] === word)); +} + +/** Match a named alternative as whole, adjacent words in the saved question quote. */ +export function questionMentionsOption(quote: string, label: string): boolean { + let mention = words(quote); + let option = words(label); + if (["use", "using", "choose"].includes(option[0] ?? "")) option.shift(); + if (option[0] === "host") { + if (option[1] === "on") option.splice(0, 2); + else { + let on = option.indexOf("on", 2); + let topicEnd = quote.indexOf(":"); + if ( + on > 1 && on <= 6 && topicEnd >= 0 + && containsWords(words(quote.slice(0, topicEnd)), option.slice(1, on)) + ) option.splice(0, on + 1); + } + } + if (["a", "an", "the"].includes(option[0] ?? "")) option.shift(); + return containsWords(mention, option); +} diff --git a/apps/server/src/questions/record-types.ts b/apps/server/src/questions/record-types.ts new file mode 100644 index 00000000..b998b0c4 --- /dev/null +++ b/apps/server/src/questions/record-types.ts @@ -0,0 +1,39 @@ +import type { ConversationPlan, Plan as Wired } from "@chopin/protocol"; +import type { Definition } from "@chopin/question"; + +export type OptionOrigin = { + origin: "chat" | "planner" | "human"; + rationale?: string; + by?: string; + source?: ConversationPlan.SourceRef; +}; + +export type DecisionEntry = { + choices: string[]; + /** Text for older answers without durable option IDs, keyed by question. */ + answers?: { [questionId: string]: string }; + owner: string; + at: number; +}; + +export type Record = { + id: string; + definition: Definition; + /** "answered" is the stored status of a decided card. */ + status: "open" | "answered" | "reopened" | "discarded" | "cancelled"; + answers?: { [question: string]: string }; + resolver?: string; + at?: number; + anchors?: Wired.WidgetAnchors; + origin: "planner" | "conversation"; + threadId?: string; + owner?: string; + decidedAt?: number; + choices?: string[]; + history: DecisionEntry[]; + prose?: Wired.Anchor[]; + optionOrigins: { [optionId: string]: OptionOrigin }; + editors: string[]; + /** Durable request keys for shared option appends, bounded by the option limit. */ + appended?: { [key: string]: string }; +}; diff --git a/apps/server/src/questions/record-validation.ts b/apps/server/src/questions/record-validation.ts new file mode 100644 index 00000000..5d569bfa --- /dev/null +++ b/apps/server/src/questions/record-validation.ts @@ -0,0 +1,162 @@ +import type { ConversationPlan, Plan as Wired } from "@chopin/protocol"; +import { limits } from "@chopin/question"; +import { assertSourceShape } from "../conversation-plan/sources"; +import type { DecisionEntry, Record } from "./record-types"; +import { invalid, object, ORIGINS, text, unix } from "./record-fields"; +import type { Known } from "./record-fields"; + +export function options(definition: unknown, pending: boolean): Known { + let source = object(definition); + if ( + !Array.isArray(source.questions) || source.questions.length === 0 + || source.questions.length > limits.MAX_QUESTIONS + ) invalid(); + let ids = new Map(); + let questions = new Map(); + for (let candidate of source.questions) { + let question = object(candidate); + let id = text(question.id); + if (questions.has(id)) invalid(); + if ( + typeof question.multiple !== "boolean" + || !Array.isArray(question.options) + || question.options.length === 0 + && !(pending && source.questions.length === 1 && question.multiple === false) + || question.options.length > limits.MAX_OPTIONS + ) invalid(); + questions.set(id, { multiple: question.multiple as boolean }); + for (let candidate of question.options) { + let option = object(candidate); + let optionId = text(option.id); + if (ids.has(optionId)) invalid(); + ids.set(optionId, id); + } + } + return { questions, options: ids }; +} + +export function choices(value: unknown, known: Known): string[] { + if (!Array.isArray(value)) invalid(); + let found = new Set(); + let counts = new Map(); + for (let id of value) { + if (typeof id !== "string" || !known.options.has(id) || found.has(id)) invalid(); + found.add(id); + let question = known.options.get(id)!; + let count = (counts.get(question) ?? 0) + 1; + if (count > (known.questions.get(question)!.multiple ? limits.MAX_OPTIONS : 1)) invalid(); + counts.set(question, count); + } + return value as string[]; +} + +export function answers(value: unknown, known: Known): { [question: string]: string } { + let entries = object(value); + for (let [id, answer] of Object.entries(entries)) { + if (!known.questions.has(id)) invalid(); + text(answer, limits.MAX_CUSTOM); + } + return value as { [question: string]: string }; +} + +export function editors(value: unknown): string[] { + if (!Array.isArray(value)) invalid(); + let found = new Set(); + for (let item of value) { + let handle = text(item); + if (found.has(handle)) invalid(); + found.add(handle); + } + return value as string[]; +} + +export function history(value: unknown, known: Known): DecisionEntry[] { + if (!Array.isArray(value)) invalid(); + for (let candidate of value) { + let entry = object(candidate); + if ( + Object.keys(entry).some(key => !["choices", "answers", "owner", "at"].includes(key)) + || !Object.hasOwn(entry, "choices") + || !Object.hasOwn(entry, "owner") || !Object.hasOwn(entry, "at") + ) invalid(); + let selected = choices(entry.choices, known); + let legacy = Object.hasOwn(entry, "answers") ? answers(entry.answers, known) : {}; + let chosenQuestions = new Set(selected.map(id => known.options.get(id)!)); + for (let id of Object.keys(legacy)) { + if (chosenQuestions.has(id)) invalid(); + chosenQuestions.add(id); + } + if (chosenQuestions.size !== known.questions.size) invalid(); + text(entry.owner); + unix(entry.at); + } + return value as DecisionEntry[]; +} + +export function optionOrigins( + value: unknown, + known: Set, + conversationThread: boolean, +): Record["optionOrigins"] { + let origins = object(value); + for (let [id, candidate] of Object.entries(origins)) { + if (!known.has(id)) invalid(); + let entry = object(candidate); + if ( + !ORIGINS.has(entry.origin as string) + || Object.keys(entry).some(key => + key !== "origin" && key !== "rationale" && key !== "by" && key !== "source" + ) + ) { + invalid(); + } + if (Object.hasOwn(entry, "rationale")) text(entry.rationale); + if (Object.hasOwn(entry, "by")) text(entry.by); + if (Object.hasOwn(entry, "source")) { + if (entry.origin !== "planner" && entry.origin !== "chat") invalid(); + try { + assertSourceShape(entry.source); + let role = (entry.source as ConversationPlan.SourceRef).role; + if ( + role !== "option" && !(role === "question" && conversationThread + && entry.origin === "planner") + ) { + invalid(); + } + } catch { + invalid(); + } + } + } + return value as Record["optionOrigins"]; +} + +export function prose(value: unknown): Wired.Anchor[] { + if (!Array.isArray(value)) invalid(); + for (let candidate of value) { + let anchor = object(candidate); + if ( + Object.keys(anchor).some(key => + key !== "epoch" && key !== "position" && key !== "digest" && key !== "orphaned" + && key !== "recoverOnNextEdit" + ) + ) invalid(); + text(anchor.epoch); + if ( + typeof anchor.position !== "string" || !anchor.position + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + anchor.position, + ) + ) { + invalid(); + } + if (typeof anchor.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(anchor.digest)) { + invalid(); + } + if (Object.hasOwn(anchor, "orphaned") && anchor.orphaned !== true) invalid(); + if (Object.hasOwn(anchor, "recoverOnNextEdit")) { + if (anchor.recoverOnNextEdit !== true || anchor.orphaned !== true) invalid(); + } + } + return value as Wired.Anchor[]; +} diff --git a/apps/server/src/questions/records.bounds.test.ts b/apps/server/src/questions/records.bounds.test.ts new file mode 100644 index 00000000..cbbef2b7 --- /dev/null +++ b/apps/server/src/questions/records.bounds.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { normalizeRecord } from "./records"; + +test("conversation thread IDs accept 200 UTF-16 units and reject 201", () => { + let record = { + id: "card-1", + status: "open", + origin: "conversation", + threadId: "🧪".repeat(100), + definition: { + questions: [{ + id: "question-1", + header: "Auth", + question: "Which system?", + multiple: false, + options: [], + }], + }, + }; + expect(record.threadId).toHaveLength(200); + expect(normalizeRecord(record).threadId).toBe(record.threadId); + expect(() => normalizeRecord({ ...record, threadId: record.threadId + "x" })) + .toThrow("hosted channel has an invalid question record"); +}); + +test("durable option append keys survive record normalization and reject foreign option IDs", () => { + let record = { + id: "card-1", + status: "open", + definition: { + questions: [{ id: "question-1", multiple: false, options: [{ id: "option-1" }] }], + }, + appended: { "key-once-0001": "option-1" }, + }; + expect(normalizeRecord(record).appended).toEqual(record.appended); + for ( + let appended of [ + { bad: "option-1" }, + { "key-once-0001": "foreign-option" }, + { "key-once-0001": "option-1", "key-once-0002": "option-1" }, + [], + null, + ] + ) { + expect(() => normalizeRecord({ ...record, appended })) + .toThrow("hosted channel has an invalid question record"); + } +}); diff --git a/apps/server/src/questions/records.reopen.test.ts b/apps/server/src/questions/records.reopen.test.ts new file mode 100644 index 00000000..e7d821fc --- /dev/null +++ b/apps/server/src/questions/records.reopen.test.ts @@ -0,0 +1,182 @@ +import { expect, test } from "bun:test"; + +import { normalizeRecord, questionMentionsOption } from "./records"; + +test("question provenance requires the option's named terms, including D02 hosting labels", () => { + let question = "hosting for the beta: managed app service or our own server?"; + expect(questionMentionsOption(question, "Use a managed app service")).toBe(true); + expect(questionMentionsOption(question, "Host on our own server")).toBe(true); + expect(questionMentionsOption(question, "Host on a small VPS")).toBe(false); + expect(questionMentionsOption("Should we use GitHub Apps or OAuth?", "Redis")).toBe(false); + expect(questionMentionsOption("Should we use GitHub Apps or OAuth?", "GitHub Apps")).toBe(true); + expect(questionMentionsOption("Should we use GitHub Apps or OAuth?", "OAuth")).toBe(true); + expect(questionMentionsOption("Should we use Auth0?", "Auth")).toBe(false); +}); + +let definition = { + questions: [ + { + id: "q1", + header: "First", + question: "First choice?", + multiple: true, + options: Array.from({ length: 12 }, (_, index) => ({ + id: `a${index}`, + label: `A ${index}`, + description: "", + })), + }, + { + id: "q2", + header: "Second", + question: "Second choice?", + multiple: true, + options: Array.from({ length: 12 }, (_, index) => ({ + id: `b${index}`, + label: `B ${index}`, + description: "", + })), + }, + ], +}; + +let base = { + id: "w", + definition, + status: "reopened", + origin: "planner", + optionOrigins: {}, + editors: [], +}; + +test("an open conversation card can have no quoted options yet", () => { + let pending = { + ...base, + status: "open", + origin: "conversation", + threadId: "thread-a", + definition: { + questions: [{ + id: "q1", + header: "Auth", + question: "Which system?", + multiple: false, + options: [], + }], + }, + }; + expect(normalizeRecord(pending).definition.questions[0]?.options).toEqual([]); + expect(normalizeRecord({ ...pending, status: "discarded" }).status).toBe("discarded"); + expect(() => normalizeRecord({ ...pending, status: "answered" })).toThrow(/invalid/); + expect(() => normalizeRecord({ ...pending, origin: "planner" })).toThrow(/invalid/); +}); + +test("history accepts more than 20 choices across legacy questions", () => { + let all = definition.questions.flatMap(question => question.options.map(option => option.id)); + let record = normalizeRecord({ + ...base, + history: [{ choices: all, owner: "ana", at: 1 }], + }); + expect(record.history[0]?.choices).toEqual(all); +}); + +test("history retains a mixed ID and text answer without assigning an ID to the text", () => { + let record = normalizeRecord({ + ...base, + history: [{ choices: ["a0"], answers: { q2: "A custom answer" }, owner: "ana", at: 1 }], + }); + expect(record.history[0]).toEqual({ + choices: ["a0"], + answers: { q2: "A custom answer" }, + owner: "ana", + at: 1, + }); +}); + +test("history rejects overlap, missing answers, unknown IDs, and malformed text", () => { + let invalid = [ + { choices: ["a0"], answers: { q1: "Duplicate", q2: "Text" }, owner: "ana", at: 1 }, + { choices: ["a0"], owner: "ana", at: 1 }, + { choices: ["unknown"], answers: { q2: "Text" }, owner: "ana", at: 1 }, + { choices: ["a0"], answers: { q2: "" }, owner: "ana", at: 1 }, + { choices: ["a0"], answers: { q2: "x".repeat(4001) }, owner: "ana", at: 1 }, + { choices: ["a0"], answers: { wrong: "Text" }, owner: "ana", at: 1 }, + ]; + for (let entry of invalid) { + expect(() => normalizeRecord({ ...base, history: [entry] })).toThrow(/invalid question record/); + } +}); + +test("current choice bounds are checked per question", () => { + let single = { + ...definition, + questions: definition.questions.map((question, index) => ({ + ...question, + multiple: index !== 0, + })), + }; + expect(() => + normalizeRecord({ + ...base, + definition: single, + choices: ["a0", "a1"], + }) + ).toThrow(/invalid question record/); + expect(() => + normalizeRecord({ + ...base, + answers: { wrong: "Text" }, + }) + ).toThrow(/invalid question record/); +}); + +test("option origins restore without a source and reject malformed optional sources", () => { + let record = { + ...base, + optionOrigins: { a0: { origin: "planner" as const, rationale: "Repository evidence" } }, + }; + expect(normalizeRecord(record).optionOrigins.a0).toEqual(record.optionOrigins.a0); + let cited = { + ...record, + optionOrigins: { + a0: { + origin: "planner" as const, + source: { + messageId: "m1", + author: { kind: "member" as const, handle: "jev" }, + quote: "Lexical", + start: 0, + end: 7, + role: "option" as const, + }, + }, + }, + }; + expect(normalizeRecord(cited).optionOrigins.a0).toEqual(cited.optionOrigins.a0); + let question = { + ...cited, + origin: "conversation" as const, + threadId: "thread-a", + optionOrigins: { + a0: { + ...cited.optionOrigins.a0, + source: { ...cited.optionOrigins.a0.source, role: "question" as const }, + }, + }, + }; + expect(normalizeRecord(question).optionOrigins.a0).toEqual(question.optionOrigins.a0); + expect(() => normalizeRecord({ ...question, origin: "planner" })).toThrow( + /invalid question record/, + ); + expect(() => + normalizeRecord({ + ...cited, + optionOrigins: { + a0: { + ...cited.optionOrigins.a0, + source: { ...cited.optionOrigins.a0.source, role: "reason" }, + }, + }, + }) + ).toThrow(/invalid question record/); +}); diff --git a/apps/server/src/questions/records.ts b/apps/server/src/questions/records.ts new file mode 100644 index 00000000..e4d5f29d --- /dev/null +++ b/apps/server/src/questions/records.ts @@ -0,0 +1,80 @@ +/** Durable decision-card fields and legacy questionnaire defaults. */ + +import { limits as dialectLimits } from "@chopin/dialect"; +import { invalid, object, STATUSES, text, unix } from "./record-fields"; +import { + answers, + choices, + editors, + history, + optionOrigins, + options, + prose, +} from "./record-validation"; +import type { Record } from "./record-types"; + +export { matchesQuestionSource, questionMentionsOption } from "./record-provenance"; +export type { DecisionEntry, OptionOrigin, Record } from "./record-types"; + +export function isOpenStatus(status: Record["status"]): boolean { + return status === "open" || status === "reopened"; +} + +/** Fill only fields that did not exist on stored Planner questionnaires. */ +export function normalizeRecord(raw: unknown): Record { + let value = object(raw); + text(value.id); + if (!STATUSES.has(value.status as string)) invalid(); + let pending = value.origin === "conversation" && typeof value.threadId === "string" + && !!value.threadId.trim() + && (value.status === "open" || value.status === "reopened" || value.status === "discarded"); + let known = options(value.definition, pending); + if (Object.hasOwn(value, "resolver")) text(value.resolver); + if (Object.hasOwn(value, "at")) unix(value.at); + if ( + Object.hasOwn(value, "origin") && value.origin !== "planner" + && value.origin !== "conversation" + ) invalid(); + if (Object.hasOwn(value, "threadId")) text(value.threadId, dialectLimits.MAX_ID); + if (Object.hasOwn(value, "owner")) text(value.owner); + if (Object.hasOwn(value, "decidedAt")) unix(value.decidedAt); + if (Object.hasOwn(value, "choices")) choices(value.choices, known); + if (Object.hasOwn(value, "answers")) answers(value.answers, known); + if (Object.hasOwn(value, "history")) history(value.history, known); + if (Object.hasOwn(value, "prose")) prose(value.prose); + if (Object.hasOwn(value, "optionOrigins")) { + optionOrigins( + value.optionOrigins, + new Set(known.options.keys()), + value.origin === "conversation" && typeof value.threadId === "string", + ); + } + if (Object.hasOwn(value, "editors")) editors(value.editors); + if (Object.hasOwn(value, "appended")) { + let appended = object(value.appended); + let seen = new Set(); + if (Object.keys(appended).length > known.options.size) invalid(); + for (let [key, id] of Object.entries(appended)) { + if ( + !/^[A-Za-z0-9_-]{8,64}$/.test(key) || typeof id !== "string" + || !known.options.has(id) || seen.has(id) + ) invalid(); + seen.add(id); + } + } + let record = value as Record; + return { + ...record, + origin: Object.hasOwn(value, "origin") ? record.origin : "planner", + history: Object.hasOwn(value, "history") ? record.history : [], + optionOrigins: Object.hasOwn(value, "optionOrigins") ? record.optionOrigins : {}, + editors: Object.hasOwn(value, "editors") ? record.editors : [], + ...(!Object.hasOwn(value, "owner") && record.resolver !== undefined + ? { owner: record.resolver } + : {}), + ...(record.status === "answered" && !Object.hasOwn(value, "decidedAt") + && record.at !== undefined + ? { decidedAt: record.at } + : {}), + }; +} diff --git a/apps/server/src/questions/service-append-option.ts b/apps/server/src/questions/service-append-option.ts new file mode 100644 index 00000000..da5871fd --- /dev/null +++ b/apps/server/src/questions/service-append-option.ts @@ -0,0 +1,188 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Store from "./store"; +import { announce, emit, pending } from "./card-notifications"; +import { isOpenStatus } from "./records"; +import { OptionCapacityError } from "../conversation-plan/option-capacity"; +import { broadcast, fail, reply } from "../wire"; + +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +/** Append a keyed shared option through the same fenced staging boundary as card actions. */ +export async function appendOption( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + let refuse = (reason: Wire.AddOption.Refusal, message: string): Wire.AddOption.Reply => ({ + kind: "question:option", + ts: 0, + id: msg.id, + ok: false, + reason, + message, + }); + if (Service.implementationActive(plan)) { + return reply( + ws, + msg.rid, + refuse("implementation", "An implementation is running; decisions cannot change"), + ); + } + let outcome: Wire.AddOption.Reply | undefined; + let added: Wire.OptionAdded | undefined; + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) { + outcome = refuse("implementation", "An implementation is running; decisions cannot change"); + return; + } + let record = plan.records.get(msg.id); + let entry = Store.get(plan.questions, msg.id); + if (!record || !entry || !isOpenStatus(record.status)) { + outcome = refuse("resolved", "This question has already been decided"); + return; + } + if (entry.definition.questions.length !== 1) { + outcome = refuse("invalid", "Options cannot be added to this questionnaire"); + return; + } + let definition = Question.decision(entry.definition); + let applied = typeof msg.key === "string" && Object.hasOwn(record.appended ?? {}, msg.key) + ? record.appended?.[msg.key] + : undefined; + let existing = applied + ? definition.questions[0].options.find(option => option.id === applied) + : undefined; + if (existing) { + outcome = { + kind: "question:option", + ts: 0, + id: msg.id, + ok: true, + option: existing, + definition, + repeated: true, + }; + return; + } + if (entry.claim) { + outcome = refuse("resolving", "This question is being decided"); + return; + } + let result = Question.appendOption(definition, { + question: msg.question, + key: msg.key, + label: msg.label, + ...(msg.description === undefined ? {} : { description: msg.description }), + }, ulid()); + if (!result.ok) { + outcome = refuse(result.reason, result.message); + return; + } + let originalQuestions = plan.questions; + if (!Store.reserveOption(originalQuestions, msg.id)) { + outcome = refuse("resolving", "This question is being decided"); + return; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(msg.id, { + ...entry, + claim: undefined, + definition: result.definition, + editors: new Set(entry.editors), + suggested: undefined, + }); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(msg.id, { + ...record, + definition: result.definition, + appended: { ...record.appended, [msg.key]: result.option.id }, + optionOrigins: { + ...record.optionOrigins, + [result.option.id]: { origin: "human", by: ws.data.handle }, + }, + }); + let candidate: Plan = { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + pendingCardActions: pending(plan, record, { + kind: "option-added", + id: msg.id, + actor: ws.data.handle, + optionId: result.option.id, + label: result.option.label, + origin: "human", + }), + }; + let mutation = room.appendQuestionOption(stagedDocument, msg.id, msg.question, result.option); + if (!mutation) throw new Error("question projection is missing"); + await Service.publishStaged(plan, server, roomId, candidate, mutation); + outcome = { + kind: "question:option", + ts: 0, + id: msg.id, + ok: true, + option: result.option, + definition: result.definition, + }; + added = { + kind: "question:option-added", + ts: 0, + id: msg.id, + question: msg.question, + option: result.option, + definition: result.definition, + by: ws.data.handle, + }; + } finally { + Store.releaseOption(originalQuestions, msg.id); + stagedDocument?.doc.destroy(); + } + }).catch(err => { + if (!(err instanceof OptionCapacityError)) { + console.error("[questions] could not save the option:", err); + } + outcome = err instanceof OptionCapacityError ? refuse(err.reason, err.message) : undefined; + added = undefined; + }); + if (!outcome) return fail(ws, msg.rid, "could not save the option"); + reply(ws, msg.rid, outcome); + if (!added) return; + broadcast(server, roomId, added); + let record = plan.records.get(msg.id); + if (record?.origin === "conversation") { + announce(plan, server, roomId, msg.id); + emit(plan, { + kind: "option-added", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + optionId: added.option.id, + label: added.option.label, + origin: "human", + }); + } +} diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts new file mode 100644 index 00000000..ccf396a7 --- /dev/null +++ b/apps/server/src/questions/service-ask.ts @@ -0,0 +1,179 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import * as Anchors from "./anchors"; + +import { applyEvent } from "../conversation-plan/events"; + +import { MAX_EVENTS, MAX_THREADS } from "../conversation-plan/validation"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import type { DecisionDefinition, Definition } from "@chopin/question"; +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; +import type { Ended } from "./store"; +import type { Record } from "./records"; + +import { announce } from "./card-notifications"; +import { type AskPlacement, validatePlacement } from "./service-definition"; + +export async function ask( + plan: Plan, + server: Server, + roomId: string, + definition: Definition, + placement?: AskPlacement, + created?: () => void, +): Promise { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (definition.questions.length === 0) { + Question.reject("A questionnaire needs at least one question"); + } + let asked: Array<{ + id: string; + single: DecisionDefinition; + value: room.QuestionnaireInsertion["value"]; + waiting: Promise; + at: room.QuestionnaireInsertion["at"]; + }> = []; + await Service.exclusive(plan, async () => { + let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let records = new Map(plan.records); + let conversationPlan = plan.conversationPlan; + asked = definition.questions.map((question, index) => { + let single = Question.decision({ questions: [question] }); + // Widget and question identities are deliberately distinct. + let id = ulid(); + // Classifier capacity must not prevent an ordinary Planner questionnaire. + let available = MAX_EVENTS - conversationPlan.events.length - plan.pendingCardActions.length; + let threadId = conversationPlan.threads.length < MAX_THREADS + && question.options.length + 2 <= available + ? ulid() + : undefined; + let at = Math.floor(Date.now() / 1_000); + if (threadId) { + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${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 = conversationPlan.threads.find(thread => thread.id === threadId)!.version; + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${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, + }, + }); + } + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${id}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: conversationPlan.threads.find(thread => + thread.id === threadId + )!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at, + questionnaireId: id, + }); + } + let waiting = Store.ask(questions, id, single, id); + let value = { + id, + ...(threadId ? { thread: threadId } : {}), + 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 } : {}), + })), + }], + }; + let record: Record = { + id, + definition: single, + status: "open", + origin: "planner", + ...(threadId ? { threadId } : {}), + history: [], + optionOrigins: {}, + editors: [], + }; + if (anchors) { + record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); + } + records.set(id, record); + return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; + }); + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + try { + let mutation = room.insertQuestionnaires( + document, + asked.map(item => ({ + value: item.value, + ...(item.at ? { at: item.at } : {}), + })), + ); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document, + questions, + records, + conversationPlan, + }, mutation); + } finally { + document.doc.destroy(); + } + for (let item of asked) { + announce(plan, server, roomId, item.id); + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id: item.id, + definition: item.single, + widget: item.id, + }); + } + created?.(); + }); + + return Promise.all(asked.map(item => item.waiting)); +} diff --git a/apps/server/src/questions/service-cancel.ts b/apps/server/src/questions/service-cancel.ts new file mode 100644 index 00000000..e02d2678 --- /dev/null +++ b/apps/server/src/questions/service-cancel.ts @@ -0,0 +1,140 @@ +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; +import * as room from "../plan/room"; +import * as Store from "./store"; +import { announce, emit, pending } from "./card-notifications"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +export async function cancel( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); + if (!claimed.ok) { + return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); + } + let mutationError: unknown; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (plan.questions.open.get(msg.id) !== claimed.claim.entry) { + throw new Error("questionnaire is no longer open"); + } + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + try { + let mutation: room.Mutation | undefined; + try { + mutation = room.removeQuestionnaire(document, msg.id); + } catch (err) { + mutationError = err; + return; + } + let record = plan.records.get(msg.id); + let records = new Map(plan.records); + if (record) { + records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); + } + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete = Store.stage(questions, claimed.claim); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document, + questions, + records, + pendingCardActions: record + ? pending(plan, record, { + kind: "discarded", + id: msg.id, + actor: ws.data.handle, + }) + : plan.pendingCardActions, + }, mutation); + finish = complete; + } finally { + document.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (!finish) Store.rollback(plan.questions, claimed.claim); + if (failure) { + return fail( + ws, + msg.rid, + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not cancel the questionnaire", + ); + } + if (mutationError) { + console.error("[questions] could not remove the node:", mutationError); + return reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: false, + reason: "resolving", + }); + } + if (!finish) return fail(ws, msg.rid, "could not cancel the questionnaire"); + finish(); + let record = plan.records.get(msg.id); + for ( + let notify of [ + () => + reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: true, + resolver: ws.data.handle, + }), + () => + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "cancelled", + resolver: ws.data.handle, + }), + () => announce(plan, server, roomId, msg.id), + () => + emit(plan, { + kind: "discarded", + id: msg.id, + ...(record?.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + }), + ] + ) { + try { + notify(); + } catch (err) { + console.error("[questions] could not announce a cancelled questionnaire:", err); + } + } +} diff --git a/apps/server/src/questions/service-definition.ts b/apps/server/src/questions/service-definition.ts new file mode 100644 index 00000000..5fc1ef3f --- /dev/null +++ b/apps/server/src/questions/service-definition.ts @@ -0,0 +1,71 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; + +import * as room from "../plan/room"; + +import type { Plan as Wired } from "@chopin/protocol"; +import type { Answer, Definition } from "@chopin/question"; + +import type { Plan } from "../plan/service"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type AskPlacement = { + revision: number; + blocks: Array>; +}; + +export function identify(raw: unknown): Definition { + let definition = Question.normalize(raw); + return { + questions: definition.questions.map(question => ({ + ...question, + id: ulid(), + options: question.options.map(option => ({ ...option, id: ulid() })), + })), + }; +} + +export function decide( + entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, + definition: Definition, +): { [question: string]: string } { + if (entry.status !== "answered") return {}; + let out: { [question: string]: string } = {}; + definition.questions.forEach((question, index) => { + let answer = entry.answers[index]; + if (answer) out[question.id] = Question.summarize(answer); + }); + return out; +} + +export function validatePlacement( + plan: Plan, + definition: Definition, + placement: AskPlacement, +): Wired.Anchor[][] { + if (placement.revision !== plan.revision) { + throw new Error("The plan changed; read it again before asking."); + } + if (placement.blocks.length !== definition.questions.length) { + throw new Error("Give one placement for every question."); + } + + let digests = room.digests(plan.document); + return placement.blocks.map(blocks => { + if (blocks.length === 0) { + if (room.hasProse(plan.document)) { + throw new Error("Relate each question to prose, or write its context first."); + } + return []; + } + + return blocks.map(block => { + let current = digests[block.index]; + if (!current) throw new Error(`no block at index ${block.index}`); + if (current !== block.digest) { + throw new Error(`block ${block.index} has changed; read the plan again`); + } + return room.anchorAt(plan.document, block.index, current); + }); + }); +} diff --git a/apps/server/src/questions/service-discard.ts b/apps/server/src/questions/service-discard.ts new file mode 100644 index 00000000..4d21e2bf --- /dev/null +++ b/apps/server/src/questions/service-discard.ts @@ -0,0 +1,143 @@ +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type DiscardResult = + | { ok: true; resolver: string } + | { ok: false; reason: "resolving" } + | { ok: false; reason: "resolved"; status: "discarded"; resolver: string }; + +export async function discard( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let respond = (body: DiscardResult) => + reply(ws, msg.rid, { kind: "question:discard", ts: 0, id: msg.id, ...body }); + let result: DiscardResult | undefined; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + let live = false; + let threadId: string | undefined; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(msg.id); + if (!record || record.status === "discarded" || record.status === "cancelled") { + result = { + ok: false, + reason: "resolved", + status: "discarded", + resolver: record?.resolver ?? "system", + }; + return; + } + live = isOpenStatus(record.status); + threadId = record.threadId; + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete: (() => Store.Ended) | undefined; + if (live) { + let entry = stagedQuestions.open.get(msg.id); + if (entry) { + stagedQuestions.open.set(msg.id, { + ...entry, + editors: new Set(entry.editors), + }); + } + let claimed = Store.claimCancel(stagedQuestions, msg.id, ws.data.handle); + if (!claimed.ok) { + result = claimed.reason === "resolving" + ? { ok: false, reason: "resolving" } + : { + ok: false, + reason: "resolved", + status: "discarded", + resolver: claimed.resolver, + }; + return; + } + complete = Store.stage(stagedQuestions, claimed.claim); + } + 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.projectCard(stagedDocument, msg.id, { status: "discarded" }); + let records = new Map(plan.records); + records.set(msg.id, { ...record, status: "discarded", resolver: ws.data.handle }); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + pendingCardActions: pending(plan, record, { + kind: "discarded", + id: msg.id, + actor: ws.data.handle, + }), + }, mutation); + finish = complete; + result = { ok: true, resolver: ws.data.handle }; + } finally { + stagedDocument.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (failure) { + if ( + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ) { + return fail(ws, msg.rid, failure.message); + } + console.error("[questions] could not discard the card:", failure); + return respond({ ok: false, reason: "resolving" }); + } + if (!result) return respond({ ok: false, reason: "resolving" }); + if (!result.ok) return respond(result); + finish?.(); + respond(result); + if (live) { + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "cancelled", + resolver: ws.data.handle, + }); + } + announce(plan, server, roomId, msg.id); + emit(plan, { + kind: "discarded", + id: msg.id, + ...(threadId ? { threadId } : {}), + actor: ws.data.handle, + }); +} diff --git a/apps/server/src/questions/service-draft.ts b/apps/server/src/questions/service-draft.ts new file mode 100644 index 00000000..fe7cc888 --- /dev/null +++ b/apps/server/src/questions/service-draft.ts @@ -0,0 +1,117 @@ +import * as Store from "./store"; + +import { fail, relay, reply, tell } from "../wire"; + +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket } from "../wire"; +import type { Questions } from "./store"; + +import { announce, meta } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export function greet(plan: Plan, ws: Socket): void { + let cards = [...plan.records.values()].map(record => ({ + id: record.id, + meta: meta(plan, record), + })); + if (cards.length > 0) tell(ws, { kind: "question:metas", ts: 0, cards }); + let open = Store.outstanding(plan.questions); + if (open.length === 0) return; + tell(ws, { kind: "question:sync", ts: 0, open }); +} + +export function open(plan: Plan, ws: Socket, msg: Request): void { + reply(ws, msg.rid, { kind: "question:open", ts: 0, ...Store.snapshot(plan.questions, msg.id) }); +} + +export async function edit(plan: Plan, ws: Socket, msg: Request): Promise { + let outcome: Store.Edited | undefined; + let failure: unknown; + await Service.exclusive(plan, async () => { + let originalQuestions = plan.questions; + if (!Store.reserveEdit(originalQuestions, msg.id)) { + outcome = Store.edit(originalQuestions, msg.id, msg.patch); + return; + } + try { + let entry = Store.get(originalQuestions, msg.id)!; + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(msg.id, { + ...entry, + claim: undefined, + editors: new Set(entry.editors), + }); + outcome = Store.edit(stagedQuestions, msg.id, msg.patch, ws.data.handle); + if (outcome.open && outcome.accepted && outcome.applied) { + let record = plan.records.get(msg.id); + if (!record) throw new Error("card record is missing"); + let records = new Map(plan.records); + records.set(msg.id, { + ...record, + editors: [...new Set([...record.editors, ...stagedQuestions.open.get(msg.id)!.editors])], + }); + await Service.publishStaged(plan, plan.server, plan.id, { + ...plan, + questions: stagedQuestions, + records, + }); + } + } catch (err) { + failure = err; + } finally { + Store.releaseEdit(originalQuestions, msg.id); + } + }); + if (failure || !outcome) { + return fail(ws, msg.rid, "could not save questionnaire draft"); + } + reply(ws, msg.rid, { kind: "question:edit", ts: 0, id: msg.id, ...outcome }); + // A no-op patch is acknowledged but not relayed: peers have nothing to do + // with it and it did not move the revision. + if (!outcome.open || !outcome.accepted || !outcome.applied) return; + relay(ws, { + kind: "question:edit", + ts: 0, + id: msg.id, + open: true, + accepted: true, + applied: true, + revision: outcome.revision, + patch: msg.patch, + editor: ws.data.handle, + }); + announce(plan, plan.server, plan.id, msg.id); +} + +export function focus(plan: Plan, ws: Socket, msg: Wire.Presence.Input): void { + let person = { + client: ws.data.client, + handle: ws.data.handle, + ...(msg.question ? { question: msg.question } : {}), + ...(msg.field ? { field: msg.field } : {}), + }; + if (!Store.focus(plan.questions, msg.id, person)) return; + relay(ws, { kind: "question:presence", ts: 0, ...person, id: msg.id }); +} + +export function away(plan: Plan, ws: Socket): void { + for (let id of Store.away(plan.questions, ws.data.client)) { + relay(ws, { + kind: "question:presence", + ts: 0, + id, + client: ws.data.client, + handle: ws.data.handle, + }); + } +} + +export function shutdown(questions: Questions): void { + Store.shutdown(questions); +} diff --git a/apps/server/src/questions/service-relationships.ts b/apps/server/src/questions/service-relationships.ts new file mode 100644 index 00000000..92512917 --- /dev/null +++ b/apps/server/src/questions/service-relationships.ts @@ -0,0 +1,128 @@ +import * as Anchors from "./anchors"; + +import * as Prose from "./prose"; +import * as room from "../plan/room"; + +import type { Plan as Wired } from "@chopin/protocol"; + +import type { Plan } from "../plan/service"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export function anchors(plan: Plan): Wired.WidgetAnchors[] { + return [...plan.records.values()].map(record => Anchors.read(record)); +} + +export function setProse(plan: Plan, id: string, anchors: Wired.Anchor[]): void { + let record = plan.records.get(id); + if (!record) throw new Error(`no questionnaire ${id}`); + plan.records.set(id, { ...record, prose: anchors }); +} + +export function prose(plan: Plan): Wired.ProseAnchors[] { + let out: Wired.ProseAnchors[] = []; + for (let record of plan.records.values()) { + if (!record.prose?.length) continue; + out.push({ + widget: record.id, + anchors: record.prose, + orphaned: Prose.orphaned(record.prose), + }); + } + return out; +} + +export function rebase(plan: Plan, previousSource = plan.persistence?.committedSource): void { + for (let [id, record] of plan.records) { + let value = Anchors.read(record); + let questions: { [question: string]: Wired.AnchorSet } = {}; + + for (let [question, set] of Object.entries(value.questions)) { + questions[question] = carry(plan, set); + } + + plan.records.set(id, { + ...record, + anchors: { widget: value.widget, questions }, + ...(record.prose ? { prose: Prose.carry(plan.document, record.prose, previousSource) } : {}), + }); + } +} + +export function carry(plan: Plan, set: Wired.AnchorSet): Wired.AnchorSet { + let anchors = room.rebase(plan.document, set.anchors); + let lost = anchors.some(anchor => anchor.orphaned); + return { + anchors, + pending: set.pending || lost, + ...(lost ? { reason: "orphaned" as const } : set.reason ? { reason: set.reason } : {}), + }; +} + +export function invalidate(plan: Plan, reason: Wired.AnchorReason): void { + for (let [id, record] of plan.records) { + plan.records.set(id, { ...record, anchors: Anchors.invalidate(record, reason) }); + } +} + +export function outstanding(plan: Plan): Anchors.Pending[] { + return [...plan.records.values()].flatMap(record => Anchors.pending(record)); +} + +export function relate( + plan: Plan, + widget: string, + question: string, + blocks: Array<{ index: number; digest: string }>, +): string | undefined { + let record = plan.records.get(widget); + if (!record) return `no questionnaire ${widget}`; + + let current = room.digests(plan.document); + let anchors: Wired.Anchor[] = []; + + for (let block of blocks) { + let hash = current[block.index]; + if (!hash) return `no block at index ${block.index}`; + // The digest is how the agent says which block it meant. If it does not + // match, the plan moved between reading and anchoring, and quietly + // anchoring the block that happens to be there now would be worse than + // saying so. + if (hash !== block.digest) return `block ${block.index} has changed; read the plan again`; + anchors.push(room.anchorAt(plan.document, block.index, hash)); + } + + plan.records.set(widget, { + ...record, + anchors: Anchors.set(Anchors.read(record), question, anchors), + }); + return undefined; +} + +export type Placement = { + widget: string; + blocks: Array<{ index: number; digest: string }>; +}; + +export function place(plan: Plan, updates: Placement[]): room.Mutation | undefined { + let records = [...plan.records.values()]; + let placements: room.QuestionnairePlacement[] = []; + let byWidget = new Map(updates.map(update => [update.widget, update])); + + for (let record of records) { + let update = byWidget.get(record.id); + if (!update || record.definition.questions.length !== 1 || update.blocks.length === 0) continue; + + let home = update.blocks[0]!; + let before = records.slice(0, records.indexOf(record)).findLast(candidate => { + if (candidate.definition.questions.length !== 1) return false; + let question = candidate.definition.questions[0]; + if (!question) return false; + let anchor = Anchors.read(candidate).questions[question.id]?.anchors[0]; + return anchor !== undefined && room.matchesAnchor(plan.document, anchor, home.index); + }); + + placements.push({ id: update.widget, at: home, ...(before ? { after: before.id } : {}) }); + } + + return room.placeQuestionnaires(plan.document, placements); +} diff --git a/apps/server/src/questions/service-reopen.ts b/apps/server/src/questions/service-reopen.ts new file mode 100644 index 00000000..30aade5b --- /dev/null +++ b/apps/server/src/questions/service-reopen.ts @@ -0,0 +1,216 @@ +import { rebase } from "./service-relationships"; +import { parse } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import type { Record } from "./records"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type ReopenResult = + | { ok: true } + | { ok: false; reason: "not-decided" | "resolving" }; + +export async function reopen( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let respond = (body: { ok: true } | { ok: false; reason: "not-decided" | "resolving" }) => + reply(ws, msg.rid, { kind: "question:reopen", ts: 0, id: msg.id, ...body }); + let result: ReopenResult | undefined; + let failure: unknown; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(msg.id); + if (!record || record.status !== "answered") { + result = { ok: false, reason: "not-decided" }; + return; + } + let selected = new Set(record.choices ?? []); + let known = new Set( + record.definition.questions.flatMap(question => question.options.map(option => option.id)), + ); + if ( + [...selected].some(id => !known.has(id)) + || selected.size !== (record.choices ?? []).length + ) { + result = { ok: false, reason: "resolving" }; + return; + } + let owner = record.owner ?? record.resolver ?? "unknown"; + let at = record.decidedAt ?? record.at ?? Math.floor(Date.now() / 1_000); + let previous: room.CardChange["previous"] = {}; + let text: { [question: string]: string } = {}; + for (let question of record.definition.questions) { + let ids = question.options.filter(option => selected.has(option.id)).map(option => + option.id + ); + if (ids.length > (question.multiple ? Question.limits.MAX_OPTIONS : 1)) { + result = { ok: false, reason: "resolving" }; + return; + } + if (ids.length) { + previous[question.id] = { + choices: ids, + by: owner, + at: new Date(at * 1_000).toISOString(), + }; + } else { + let answer = record.answers?.[question.id]; + if ( + typeof answer !== "string" || !answer.trim() + || answer.length > Question.limits.MAX_CUSTOM + ) { + result = { ok: false, reason: "resolving" }; + return; + } + text[question.id] = answer; + previous[question.id] = { + choices: [], + value: answer, + by: owner, + at: new Date(at * 1_000).toISOString(), + }; + } + } + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let vector = Y.encodeStateVector(stagedDocument.doc); + let previousSource = room.project(stagedDocument); + let candidate: Plan = { + ...plan, + document: stagedDocument, + records: new Map(plan.records), + }; + // Rebase on the staged document so a moved paragraph is found by its + // current live anchor, without changing the live sidecar before commit. + rebase(candidate); + room.projectCard(stagedDocument, msg.id, { + status: "reopened", + clearAnswers: true, + previous, + }); + let prose = candidate.records.get(msg.id)?.prose?.filter(anchor => !anchor.orphaned) ?? []; + if (prose.length === 1) { + let blocks = parse(room.project(stagedDocument)).children; + let matches = room.digests(stagedDocument).flatMap((_, index) => + room.matchesAnchor(stagedDocument, prose[0]!, index) ? [index] : [] + ); + if (matches.length === 1 && blocks[matches[0]!]?.type === "paragraph") { + let index = matches[0]!; + room.placeQuestionnaires(stagedDocument, [{ + id: msg.id, + at: { index, digest: room.digests(stagedDocument)[index]! }, + renew: true, + }]); + } + } + rebase(candidate, previousSource); + let next: Record = { + ...candidate.records.get(msg.id)!, + status: "reopened", + history: [...record.history, { + choices: [...selected], + ...(Object.keys(text).length ? { answers: text } : {}), + owner, + at, + }], + }; + delete next.answers; + delete next.choices; + delete next.resolver; + delete next.at; + delete next.owner; + delete next.decidedAt; + candidate.records.set(msg.id, next); + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + Store.reopen(questions, msg.id, record.definition, msg.id); + await Service.publishStaged(plan, server, roomId, { + ...candidate, + questions, + pendingCardActions: pending(plan, record, { + kind: "reopened", + id: msg.id, + actor: ws.data.handle, + }), + }, { + update: Y.encodeStateAsUpdate(stagedDocument.doc, vector), + source: room.project(stagedDocument), + }); + result = { ok: true }; + // Keep publication in the room queue: a following discard must not + // overtake this fresh draft's metadata and asked announcement. + for ( + let notify of [ + () => respond({ ok: true }), + () => announce(plan, server, roomId, msg.id), + () => + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id: msg.id, + definition: record.definition, + widget: msg.id, + }), + () => + emit(plan, { + kind: "reopened", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + }), + ] + ) { + try { + notify(); + } catch (err) { + console.error("[questions] could not announce a reopened card:", err); + } + } + } finally { + stagedDocument.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (failure) { + if ( + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ) { + return fail(ws, msg.rid, failure.message); + } + console.error("[questions] could not reopen the card:", failure); + return respond({ ok: false, reason: "resolving" }); + } + if (!result) return respond({ ok: false, reason: "resolving" }); + if (!result.ok) respond(result); +} diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts new file mode 100644 index 00000000..35029e2c --- /dev/null +++ b/apps/server/src/questions/service-submit.ts @@ -0,0 +1,222 @@ +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import { activeSettleDeferral } from "../conversation-plan/preference"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import type { Record } from "./records"; +import { appendProseEffect, proseIntent } from "../conversation-plan/prose-job"; + +import type { CardEvent } from "./card-event"; +import { announce, emit, pending } from "./card-notifications"; +import { decide } from "./service-definition"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function submit( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, + onCommitted?: (intent: NonNullable>) => void, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let paused = () => { + let record = plan.records.get(msg.id); + let thread = plan.conversationPlan.threads.find(item => item.id === record?.threadId); + return !!thread && !!activeSettleDeferral(thread, plan.conversationPlan.events); + }; + if (paused()) { + return reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: false, + reason: "invalid", + message: "This decision is paused pending verification", + }); + } + let claimed = Store.claimSubmit( + plan.questions, + msg.id, + msg.revision, + ws.data.handle, + msg.suggestedOptionId, + ); + if (!claimed.ok) { + return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ...claimed }); + } + let invalid: string | undefined; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + let optionIds: string[] = []; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (paused()) { + invalid = "This decision is paused pending verification"; + return; + } + let record = plan.records.get(msg.id); + if (!record || !isOpenStatus(record.status)) { + invalid = "This decision is no longer open"; + return; + } + if (plan.questions.open.get(msg.id) !== claimed.claim.entry) { + invalid = "This decision is no longer open"; + return; + } + let stagedDocument: room.Document | undefined; + try { + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let answers = decide({ status: "answered", answers: claimed.answers }, record.definition); + let chosen: { [question: string]: string[] } = {}; + record.definition.questions.forEach((question, index) => { + let ids = claimed.answers[index]?.optionIds; + if (ids?.length) chosen[question.id] = ids; + }); + optionIds = Object.values(chosen).flat(); + let at = Math.floor(Date.now() / 1_000); + let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; + let mutation = room.projectAnswer(stagedDocument, msg.id, answers, settled, chosen); + let records = new Map(plan.records); + let answered: Record = { + ...record, + status: "answered", + answers, + resolver: ws.data.handle, + at, + owner: ws.data.handle, + decidedAt: at, + choices: optionIds, + editors: [...new Set([...record.editors, ...claimed.claim.entry.editors])], + }; + records.set(msg.id, answered); + let intent = proseIntent(answered); + let effects = appendProseEffect( + plan.conversationPlanPendingEffects, + plan.conversationPlanEffects, + answered, + ); + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete = Store.stage(stagedQuestions, claimed.claim); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + conversationPlanPendingEffects: effects, + pendingCardActions: pending( + plan, + record, + { + kind: "decided", + id: msg.id, + actor: ws.data.handle, + optionIds, + }, + at, + claimed.answers.map(Question.summarize).join("; "), + ), + }, mutation); + finish = complete; + if (intent) { + try { + onCommitted?.(intent); + } catch (error) { + console.error("[questions] could not capture saved decision claimant:", error); + } + } + } catch (err) { + if (err instanceof room.QuestionnaireProjectionError) invalid = err.message; + else failure = err; + } finally { + stagedDocument?.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (!finish) { + Store.rollback(plan.questions, claimed.claim); + if (invalid) { + return reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: false, + reason: "invalid", + message: invalid, + }); + } + return fail( + ws, + msg.rid, + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not save the decision", + ); + } + finish(); + let record = plan.records.get(msg.id)!; + let event: CardEvent = { + kind: "decided", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + optionIds, + }; + for ( + let notify of [ + () => + reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: true, + answers: claimed.answers, + resolver: ws.data.handle, + }), + () => + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "answered", + resolver: ws.data.handle, + answers: claimed.answers, + }), + () => announce(plan, server, roomId, msg.id), + () => emit(plan, event), + ] + ) { + try { + notify(); + } catch (error) { + console.error("[questions] could not announce a saved decision:", error); + } + } +} diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index f007c2ce..3ab8e4e0 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -12,683 +12,46 @@ * everyone already knows how to render. */ -import { ulid } from "@chopin/dialect"; -import * as Question from "@chopin/question"; - -import * as Anchors from "./anchors"; - -import * as room from "../plan/room"; import * as Store from "./store"; -import { broadcast, fail, relay, reply, tell } from "../wire"; - -import type { Server } from "bun"; -// `Plan` is the room's plan here; the protocol namespace of the same name is -// aliased so the two cannot be confused at a glance. -import type { Plan as Wired, Question as Wire, Request } from "@chopin/protocol"; -import type { Answer, DecisionDefinition, Definition } from "@chopin/question"; -import * as Service from "../plan/service"; - -import type { Plan } from "../plan/service"; -import type { Socket, SocketData } from "../wire"; -import type { Ended, Questions } from "./store"; -export type { Questions } from "./store"; - -export type AskPlacement = { - revision: number; - blocks: Array>; -}; +export type { CardEvent } from "./card-event"; +export { insertConversationCard } from "./card-insertion"; +export { involved } from "./card-involved"; +export { announce, listen, meta } from "./card-notifications"; +export { relabelConversationOption } from "./card-option-relabel"; +export { revisePlannerCard } from "./card-planner-revision"; +export { retitle } from "./card-retitle"; +export { addServerOption } from "./card-server-options"; +export { suggest } from "./card-suggestions"; +export { + isOpenStatus, + matchesQuestionSource, + normalizeRecord, + questionMentionsOption, +} from "./records"; +export type { DecisionEntry, OptionOrigin, Record } from "./records"; +export { appendOption as addOption } from "./service-append-option"; +export { ask } from "./service-ask"; +export { cancel } from "./service-cancel"; +export { identify } from "./service-definition"; +export type { AskPlacement } from "./service-definition"; +export { discard } from "./service-discard"; +export { away, edit, focus, greet, open, shutdown } from "./service-draft"; +export { + anchors, + invalidate, + outstanding, + place, + prose, + rebase, + relate, + setProse, +} from "./service-relationships"; +export type { Placement } from "./service-relationships"; +export { reopen } from "./service-reopen"; +export { submit } from "./service-submit"; +export type { Questions, StoredOpen } from "./store"; export const create = Store.create; export const dump = Store.dump; export const restore = Store.restore; -export type { StoredOpen } from "./store"; - -/** - * Validate a questionnaire and give it durable identity. - * - * The domain assigns positional ids — `q0`, `o1` — which are fine while a - * questionnaire is a single tool result and useless the moment it is a node in - * a document the agent will rewrite around. Identity is minted once, here, - * before anything references it. - */ -export function identify(raw: unknown): Definition { - let definition = Question.normalize(raw); - return { - questions: definition.questions.map(question => ({ - ...question, - id: ulid(), - options: question.options.map(option => ({ ...option, id: ulid() })), - })), - }; -} - -/** A questionnaire as it is stored beside the plan, so it survives a restart. */ -export type Record = { - id: string; - definition: Definition; - status: "open" | "answered" | "cancelled"; - /** Question id to the answer as it reads, for projection into the plan. */ - answers?: { [question: string]: string }; - resolver?: string; - /** When it was settled, Unix seconds. Absent on one settled before we recorded it. */ - at?: number; - /** Where in the prose each of its decisions lives. */ - anchors?: Wired.WidgetAnchors; - /** - * Option-append idempotency keys to the option each created. Bounded by the - * option limit, and durable so a retry after a restart still finds it. - */ - appended?: { [key: string]: string }; -}; - -function decide( - entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, - definition: Definition, -): { [question: string]: string } { - if (entry.status !== "answered") return {}; - let out: { [question: string]: string } = {}; - definition.questions.forEach((question, index) => { - let answer = entry.answers[index]; - if (answer) out[question.id] = Question.summarize(answer); - }); - return out; -} - -/** Ask each decision independently; register its record before publishing its node. */ -export async function ask( - plan: Plan, - server: Server, - roomId: string, - definition: Definition, - placement?: AskPlacement, - created?: () => void, -): Promise { - if (Service.implementationActive(plan)) throw new Error("implementation is active"); - if (definition.questions.length === 0) { - Question.reject("A questionnaire needs at least one question"); - } - let asked: Array<{ - id: string; - single: DecisionDefinition; - value: room.QuestionnaireInsertion["value"]; - waiting: Promise; - at: room.QuestionnaireInsertion["at"]; - }> = []; - await Service.exclusive(plan, async () => { - let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; - asked = definition.questions.map((question, index) => { - let single = Question.decision({ questions: [question] }); - // Widget and question identities are deliberately distinct. - let id = ulid(); - let waiting = Store.ask(plan.questions, id, single, id); - let value = { - 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 } : {}), - })), - }], - }; - let record: Record = { id, definition: single, status: "open" }; - if (anchors) { - record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); - } - plan.records.set(id, record); - return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; - }); - - let mutation = room.insertQuestionnaires( - plan.document, - asked.map(item => ({ - value: item.value, - ...(item.at ? { at: item.at } : {}), - })), - ); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - for (let item of asked) { - broadcast(server, roomId, { - kind: "question:asked", - ts: 0, - id: item.id, - definition: item.single, - widget: item.id, - }); - } - created?.(); - }); - - return Promise.all(asked.map(item => item.waiting)); -} - -/** Validate every placement before registering records or document nodes. */ -function validatePlacement( - plan: Plan, - definition: Definition, - placement: AskPlacement, -): Wired.Anchor[][] { - if (placement.revision !== plan.revision) { - throw new Error("The plan changed; read it again before asking."); - } - if (placement.blocks.length !== definition.questions.length) { - throw new Error("Give one placement for every question."); - } - - let digests = room.digests(plan.document); - return placement.blocks.map(blocks => { - if (blocks.length === 0) { - if (room.hasProse(plan.document)) { - throw new Error("Relate each question to prose, or write its context first."); - } - return []; - } - - return blocks.map(block => { - let current = digests[block.index]; - if (!current) throw new Error(`no block at index ${block.index}`); - if (current !== block.digest) { - throw new Error(`block ${block.index} has changed; read the plan again`); - } - return room.anchorAt(plan.document, block.index, current); - }); - }); -} - -/** Everything still unanswered, for a client that has just joined. */ -export function greet(plan: Plan, ws: Socket): void { - let open = Store.outstanding(plan.questions); - if (open.length === 0) return; - tell(ws, { kind: "question:sync", ts: 0, open }); -} - -export function open(plan: Plan, ws: Socket, msg: Request): void { - reply(ws, msg.rid, { kind: "question:open", ts: 0, ...Store.snapshot(plan.questions, msg.id) }); -} - -export function edit(plan: Plan, ws: Socket, msg: Request): void | Promise { - let outcome = Store.edit(plan.questions, msg.id, msg.patch); - let finish = () => { - reply(ws, msg.rid, { kind: "question:edit", ts: 0, id: msg.id, ...outcome }); - // A no-op patch is acknowledged but not relayed: peers have nothing to do - // with it and it did not move the revision. - if (!outcome.open || !outcome.accepted || !outcome.applied) return; - relay(ws, { - kind: "question:edit", - ts: 0, - id: msg.id, - open: true, - accepted: true, - applied: true, - revision: outcome.revision, - patch: msg.patch, - editor: ws.data.handle, - }); - }; - if (outcome.open && outcome.accepted && outcome.applied) { - return Service.persist(plan).then(finish, () => { - fail(ws, msg.rid, "could not save questionnaire draft"); - }); - } - finish(); -} - -export function focus(plan: Plan, ws: Socket, msg: Wire.Presence.Input): void { - let person = { - client: ws.data.client, - handle: ws.data.handle, - ...(msg.question ? { question: msg.question } : {}), - ...(msg.field ? { field: msg.field } : {}), - }; - if (!Store.focus(plan.questions, msg.id, person)) return; - relay(ws, { kind: "question:presence", ts: 0, ...person, id: msg.id }); -} - -export function away(plan: Plan, ws: Socket): void { - for (let id of Store.away(plan.questions, ws.data.client)) { - relay(ws, { - kind: "question:presence", - ts: 0, - id, - client: ws.data.client, - handle: ws.data.handle, - }); - } -} - -export async function submit( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimSubmit(plan.questions, msg.id, msg.revision, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ...claimed }); - } - - let record = plan.records.get(msg.id); - let answers = record - ? decide({ status: "answered", answers: claimed.answers }, record.definition) - : {}; - - // Stamped once and used in both places, so the record and the plan cannot - // disagree about when the room decided. - let at = Math.floor(Date.now() / 1_000); - let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; - let mutationError: unknown; - let finish: (() => Store.Ended) | undefined; - await Service.exclusive(plan, async () => { - let mutation: room.Mutation | undefined; - try { - mutation = room.projectAnswer(plan.document, msg.id, answers, settled); - } catch (err) { - mutationError = err; - return; - } - if (record) { - plan.records.set(msg.id, { - ...record, - status: "answered", - answers, - resolver: ws.data.handle, - at, - }); - } - finish = Store.stage(plan.questions, claimed.claim); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - }); - if (mutationError) { - // The decision is not final if the plan could not be told about it. - Store.rollback(plan.questions, claimed.claim); - return reply(ws, msg.rid, { - kind: "question:submit", - ts: 0, - id: msg.id, - ok: false, - reason: "invalid", - message: mutationError instanceof Error - ? mutationError.message - : "could not record the answer", - }); - } - finish!(); - - reply(ws, msg.rid, { - kind: "question:submit", - ts: 0, - id: msg.id, - ok: true, - answers: claimed.answers, - resolver: ws.data.handle, - }); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id: msg.id, - status: "answered", - resolver: ws.data.handle, - answers: claimed.answers, - }); -} - -const ADD_OPTION_REFUSAL: { [reason in "resolved" | "resolving"]: string } = { - resolved: "This question has already been decided", - resolving: "This question is being decided", -}; - -/** - * Append an option to an open question, for everyone. - * - * Ordering follows the rest of this module: the record, the open entry and the - * plan projection change together under the plan's exclusive queue, the fenced - * commit happens, and only then does anyone hear about it. The shared draft is - * not touched, so nothing another member has already chosen can change. - */ -export async function addOption( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - let refuse = (reason: Wire.AddOption.Refusal, message: string) => - reply(ws, msg.rid, { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason, - message, - }); - if (Service.implementationActive(plan)) { - return refuse("implementation", "An implementation is running; decisions cannot change"); - } - - let outcome: Wire.AddOption.Reply | undefined; - let added: Wire.OptionAdded | undefined; - await Service.exclusive(plan, async () => { - // An implementation may have claimed the plan while this waited in the - // queue. Refuse before touching the live document: `publish` would throw - // after the Yjs mutation, leaving the room ahead of its durable state. - if (Service.implementationActive(plan)) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "implementation", - message: "An implementation is running; decisions cannot change", - }; - return; - } - let record = plan.records.get(msg.id); - let entry = Store.get(plan.questions, msg.id); - if (!record || !entry || record.status !== "open") { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "resolved", - message: ADD_OPTION_REFUSAL.resolved, - }; - return; - } - let applied = typeof msg.key === "string" && Object.hasOwn(record.appended ?? {}, msg.key) - ? record.appended![msg.key] - : undefined; - let existing = applied - ? entry.definition.questions[0].options.find(option => option.id === applied) - : undefined; - if (existing) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: true, - option: existing, - definition: entry.definition, - repeated: true, - }; - return; - } - if (entry.claim) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - message: ADD_OPTION_REFUSAL.resolving, - }; - return; - } - - let result = Question.appendOption(entry.definition, { - question: msg.question, - key: msg.key, - label: msg.label, - ...(msg.description === undefined ? {} : { description: msg.description }), - }, ulid()); - if (!result.ok) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: result.reason, - message: result.message, - }; - return; - } - - let previous = { record, definition: entry.definition }; - let mutation: room.Mutation | undefined; - try { - mutation = room.appendQuestionOption(plan.document, msg.id, msg.question, result.option); - } catch (err) { - console.error("[questions] could not add the option to the plan:", err); - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "invalid", - message: "Could not add the option", - }; - return; - } - plan.records.set(msg.id, { - ...record, - definition: result.definition, - appended: { ...record.appended, [msg.key]: result.option.id }, - }); - Store.redefine(plan.questions, msg.id, result.definition); - try { - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - } catch (err) { - plan.records.set(msg.id, previous.record); - Store.restoreDefinition(plan.questions, msg.id, previous.definition); - throw err; - } - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: true, - option: result.option, - definition: result.definition, - }; - added = { - kind: "question:option-added", - ts: 0, - id: msg.id, - question: msg.question, - option: result.option, - definition: result.definition, - by: ws.data.handle, - }; - }).catch(err => { - console.error("[questions] could not save the option:", err); - outcome = undefined; - added = undefined; - }); - - if (!outcome) return fail(ws, msg.rid, "could not save the option"); - reply(ws, msg.rid, outcome); - if (added) broadcast(server, roomId, added); -} - -/** - * Decline to answer. - * - * Terminal, and the node leaves the plan: a questionnaire cannot say it was - * cancelled, so left in place it would read as one still waiting. The record - * survives as history. - */ -export async function cancel( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); - } - let mutationError: unknown; - let finish: (() => Store.Ended) | undefined; - await Service.exclusive(plan, async () => { - let mutation: room.Mutation | undefined; - try { - mutation = room.removeQuestionnaire(plan.document, msg.id); - } catch (err) { - mutationError = err; - return; - } - let record = plan.records.get(msg.id); - if (record) { - plan.records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); - } - finish = Store.stage(plan.questions, claimed.claim); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - }); - if (mutationError) { - // The questionnaire stays open and answerable, which is a state every - // client already renders. Saying "resolving" is honest: the attempt is - // over, and trying again is the right move. - console.error("[questions] could not remove the node:", mutationError); - Store.rollback(plan.questions, claimed.claim); - return reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - }); - } - finish!(); - - reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: true, - resolver: ws.data.handle, - }); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id: msg.id, - status: "cancelled", - resolver: ws.data.handle, - }); -} - -export function shutdown(questions: Questions): void { - Store.shutdown(questions); -} - -// -- relationships --------------------------------------------------------- - -/** Every questionnaire's relationships, as an authoritative snapshot. */ -export function anchors(plan: Plan): Wired.WidgetAnchors[] { - return [...plan.records.values()].map(record => Anchors.read(record)); -} - -/** - * Bring every relationship forward onto the document as it is now. - * - * Called after anything that moves prose around, which is most things. An - * anchor whose block survived is re-expressed against it; one whose block was - * rewritten is orphaned, and the agent is told it owes a review. - */ -export function rebase(plan: Plan): void { - for (let [id, record] of plan.records) { - let value = Anchors.read(record); - let questions: { [question: string]: Wired.AnchorSet } = {}; - - for (let [question, set] of Object.entries(value.questions)) { - questions[question] = carry(plan, set); - } - - plan.records.set(id, { ...record, anchors: { widget: value.widget, questions } }); - } -} - -function carry(plan: Plan, set: Wired.AnchorSet): Wired.AnchorSet { - let anchors = room.rebase(plan.document, set.anchors); - let lost = anchors.some(anchor => anchor.orphaned); - return { - anchors, - pending: set.pending || lost, - ...(lost ? { reason: "orphaned" as const } : set.reason ? { reason: set.reason } : {}), - }; -} - -/** Mark answered results as needing review, after the plan changed beneath them. */ -export function invalidate(plan: Plan, reason: Wired.AnchorReason): void { - for (let [id, record] of plan.records) { - plan.records.set(id, { ...record, anchors: Anchors.invalidate(record, reason) }); - } -} - -/** What the agent still owes a review on. */ -export function outstanding(plan: Plan): Anchors.Pending[] { - return [...plan.records.values()].flatMap(record => Anchors.pending(record)); -} - -/** Replace where a decision lives with the blocks the agent reviewed it against. */ -export function relate( - plan: Plan, - widget: string, - question: string, - blocks: Array<{ index: number; digest: string }>, -): string | undefined { - let record = plan.records.get(widget); - if (!record) return `no questionnaire ${widget}`; - - let current = room.digests(plan.document); - let anchors: Wired.Anchor[] = []; - - for (let block of blocks) { - let hash = current[block.index]; - if (!hash) return `no block at index ${block.index}`; - // The digest is how the agent says which block it meant. If it does not - // match, the plan moved between reading and anchoring, and quietly - // anchoring the block that happens to be there now would be worse than - // saying so. - if (hash !== block.digest) return `block ${block.index} has changed; read the plan again`; - anchors.push(room.anchorAt(plan.document, block.index, hash)); - } - - plan.records.set(widget, { - ...record, - anchors: Anchors.set(Anchors.read(record), question, anchors), - }); - return undefined; -} - -export type Placement = { - widget: string; - blocks: Array<{ index: number; digest: string }>; -}; - -/** Move cards after their first related block, preserving ask order per block. */ -export function place(plan: Plan, updates: Placement[]): room.Mutation | undefined { - let records = [...plan.records.values()]; - let placements: room.QuestionnairePlacement[] = []; - let byWidget = new Map(updates.map(update => [update.widget, update])); - - for (let record of records) { - let update = byWidget.get(record.id); - if (!update || record.definition.questions.length !== 1 || update.blocks.length === 0) continue; - - let home = update.blocks[0]!; - let before = records.slice(0, records.indexOf(record)).findLast(candidate => { - if (candidate.definition.questions.length !== 1) return false; - let question = candidate.definition.questions[0]; - if (!question) return false; - let anchor = Anchors.read(candidate).questions[question.id]?.anchors[0]; - return anchor !== undefined && room.matchesAnchor(plan.document, anchor, home.index); - }); - - placements.push({ id: update.widget, at: home, ...(before ? { after: before.id } : {}) }); - } - - return room.placeQuestionnaires(plan.document, placements); -} diff --git a/apps/server/src/questions/store-edit.test.ts b/apps/server/src/questions/store-edit.test.ts new file mode 100644 index 00000000..ec0be2a8 --- /dev/null +++ b/apps/server/src/questions/store-edit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Original callback from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. +describe("Store.edit editors", () => { + it("credits applied edits, but not duplicate patches or refused edits", () => { + let questions = asked(); + let entry = Store.get(questions, "w")!; + let model = Question.crdt.Model.fromBinary(entry.model.toBinary()) + .fork() as unknown as Question.Model; + model.api.val(["q", "choice"]).set("a"); + let patch = model.api.flush(); + if (!patch) throw new Error("selection made no patch"); + let binary = [...patch.toBinary()]; + + expect(Store.edit(questions, "w", binary, "ana")).toMatchObject({ + open: true, + accepted: true, + applied: true, + }); + expect(Store.get(questions, "w")!.editors).toEqual(new Set(["ana"])); + expect(Store.edit(questions, "w", binary, "ben")).toMatchObject({ + open: true, + accepted: true, + applied: false, + }); + expect(Store.get(questions, "w")!.editors).toEqual(new Set(["ana"])); + let claim = Store.claimSubmit(questions, "w", 1, "ana"); + if (!claim.ok) throw new Error("selection was not claimable"); + expect(Store.edit(questions, "w", binary, "cy")).toMatchObject({ accepted: false }); + expect(Store.get(questions, "w")!.editors).toEqual(new Set(["ana"])); + Store.rollback(questions, claim.claim); + }); +}); diff --git a/apps/server/src/questions/store-lifecycle.ts b/apps/server/src/questions/store-lifecycle.ts new file mode 100644 index 00000000..7bfe0c53 --- /dev/null +++ b/apps/server/src/questions/store-lifecycle.ts @@ -0,0 +1,27 @@ +import * as Question from "@chopin/question"; + +import type { Definition } from "@chopin/question"; +import type { Questions } from "./store-types"; + +/** Reopen the same card with a fresh draft and no waiting Planner turn. */ +export function reopen( + questions: Questions, + id: string, + definition: Definition, + widget?: string, +): void { + if (questions.open.has(id)) Question.reject("Questionnaire is already open"); + let accepted = Question.identified(definition); + let model = Question.create(accepted); + Question.read(model, accepted); + questions.closed.delete(id); + questions.open.set(id, { + id, + definition: accepted, + ...(widget ? { widget } : {}), + model, + revision: 0, + presence: new Map(), + editors: new Set(), + }); +} diff --git a/apps/server/src/questions/store-options.test.ts b/apps/server/src/questions/store-options.test.ts new file mode 100644 index 00000000..49ec8c42 --- /dev/null +++ b/apps/server/src/questions/store-options.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +describe("Store.addOption", () => { + it("grows the definition and shared draft together", () => { + let questions = asked(); + let result = Store.addOption(questions, "w", "b", " GitHub Apps "); + expect(result).toMatchObject({ + ok: true, + revision: 1, + option: { id: "b", label: "GitHub Apps" }, + }); + let entry = Store.get(questions, "w")!; + expect(entry.definition.questions[0].options.map(option => option.label)).toEqual([ + "Auth0", + "GitHub Apps", + ]); + expect(Question.read(entry.model, entry.definition).q!.options.b).toBe(false); + expect(Store.claimSubmit(questions, "w", 0, "ana")).toMatchObject({ + ok: false, + reason: "stale", + current: 1, + }); + }); + + it("reports the second of two identical adds as a duplicate", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + expect(Store.addOption(questions, "w", "c", "github apps")).toMatchObject({ + ok: false, + reason: "duplicate", + }); + expect(Store.get(questions, "w")!.definition.questions[0].options).toHaveLength(2); + }); + + it("refuses an invalid label, a full card, a resolving card, and a closed card", () => { + let questions = asked(); + expect(Store.addOption(questions, "w", "b", " ")).toMatchObject({ + ok: false, + reason: "invalid", + }); + let claim = Store.claimCancel(questions, "w", "ana"); + expect(Store.addOption(questions, "w", "b", "GitHub Apps")).toMatchObject({ + ok: false, + reason: "closed", + }); + if (claim.ok) Store.commit(questions, claim.claim); + expect(Store.addOption(questions, "w", "b", "GitHub Apps")).toMatchObject({ + ok: false, + reason: "closed", + }); + + let full = asked(); + for (let i = 1; i < 10; i++) { + expect(Store.addOption(full, "w", `o${i}`, `Option ${i}`)).toMatchObject({ ok: true }); + } + expect(Store.addOption(full, "w", "overflow", "Overflow")).toMatchObject({ + ok: false, + reason: "full", + }); + }); + + it("reverts to exactly the previous definition, model, and revision", () => { + let questions = asked(); + let before = Store.before(questions, "w")!; + Store.addOption(questions, "w", "b", "GitHub Apps"); + Store.revert(questions, "w", before); + let entry = Store.get(questions, "w")!; + expect(entry.definition.questions[0].options).toHaveLength(1); + expect(entry.revision).toBe(0); + expect(Question.read(entry.model, entry.definition).q!.options).toEqual({ a: false }); + }); +}); diff --git a/apps/server/src/questions/store-options.ts b/apps/server/src/questions/store-options.ts new file mode 100644 index 00000000..2105f58a --- /dev/null +++ b/apps/server/src/questions/store-options.ts @@ -0,0 +1,167 @@ +import * as Question from "@chopin/question"; + +import type { DecisionDefinition, Definition, Model } from "@chopin/question"; +import type { Open, Questions } from "./store-types"; + +export type AddedOption = + | { ok: true; definition: DecisionDefinition; option: Question.Option; revision: number } + | { ok: false; reason: "full" | "duplicate" | "invalid" | "closed"; message: string }; + +export type Retitled = + | { ok: true; definition: DecisionDefinition; revision: number; applied: boolean } + | { ok: false; reason: "invalid" | "closed"; message: string }; + +/** Change only the display label of an existing option; the draft keeps its option ID. */ +export function relabelOption( + questions: Questions, + id: string, + optionId: string, + label: string, + expectedRevision: number, +): { definition: DecisionDefinition; revision: number } { + let entry = questions.open.get(id); + if (!entry || entry.claim || entry.definition.questions.length !== 1) { + throw new Error("decision is no longer open"); + } + if (entry.revision !== expectedRevision) throw new Error("stale decision revision"); + let question = entry.definition.questions[0]!; + let option = question.options.find(item => item.id === optionId); + if (!option) throw new Error("unknown decision option"); + if (option.label === label) throw new Error("option label is unchanged"); + if ( + question.options.some(item => + item.id !== optionId + && item.label.trim().toLocaleLowerCase() === label.trim().toLocaleLowerCase() + ) + ) { + throw new Error("duplicate option label"); + } + let definition = Question.decision({ + questions: [{ + ...question, + options: question.options.map(item => item.id === optionId ? { ...item, label } : item), + }], + }); + Question.read(entry.model, definition); + entry.definition = definition; + entry.revision++; + if (entry.suggested) entry.suggested = { ...entry.suggested, revision: entry.revision }; + return { definition, revision: entry.revision }; +} + +/** Reword one open decision while retaining its shared human draft. */ +export function retitle(questions: Questions, id: string, text: string): Retitled { + let entry = questions.open.get(id); + if (!entry || entry.claim || entry.definition.questions.length !== 1) { + return { ok: false, reason: "closed", message: "This decision is no longer open" }; + } + let question = typeof text === "string" ? text.trim() : ""; + if (!question || question.length > Question.limits.MAX_QUESTION) { + return { ok: false, reason: "invalid", message: "A question is 1–1000 characters" }; + } + let current = entry.definition.questions[0]!; + if (current.question === question) { + return { + ok: true, + definition: Question.decision(entry.definition), + revision: entry.revision, + applied: false, + }; + } + let definition = Question.decision({ questions: [{ ...current, question }] }); + entry.definition = definition; + entry.revision++; + if (entry.suggested) entry.suggested = { ...entry.suggested, revision: entry.revision }; + return { ok: true, definition, revision: entry.revision, applied: true }; +} + +export type Before = { + definition: Definition; + model: Model; + revision: number; + suggested?: Open["suggested"]; +}; + +/** Capture the three fields changed by an option addition. */ +export function before(questions: Questions, id: string): Before | undefined { + let entry = questions.open.get(id); + return entry && { + definition: entry.definition, + model: entry.model, + revision: entry.revision, + ...(entry.suggested ? { suggested: entry.suggested } : {}), + }; +} + +/** Grow an open decision without changing a person's existing draft choice. */ +export function addOption( + questions: Questions, + id: string, + optionId: string, + label: string, +): AddedOption { + let entry = questions.open.get(id); + if (!entry || entry.claim) { + return { ok: false, reason: "closed", message: "This decision is no longer open" }; + } + if (entry.definition.questions.length !== 1) { + return { + ok: false, + reason: "closed", + message: "Options cannot be added to this questionnaire", + }; + } + let outcome = Question.addOption( + Question.decision(entry.definition), + entry.model, + optionId, + label, + ); + if (!outcome.ok) return outcome; + entry.definition = outcome.definition; + entry.model = outcome.model; + entry.revision++; + if (entry.suggested) entry.suggested = { ...entry.suggested, revision: entry.revision }; + return { + ok: true, + definition: outcome.definition, + option: outcome.option, + revision: entry.revision, + }; +} + +/** Restore the original shape if a caller discards an uncommitted addition. */ +export function revert(questions: Questions, id: string, previous: Before): void { + let entry = questions.open.get(id); + if (!entry) return; + entry.definition = previous.definition; + entry.model = previous.model; + entry.revision = previous.revision; + entry.suggested = previous.suggested; +} + +/** Keep draft edits and resolution claims off a card during its fenced option commit. */ +export function reserveOption(questions: Questions, id: string): boolean { + let entry = questions.open.get(id); + if (!entry || entry.claim) return false; + entry.claim = "option"; + return true; +} + +export function releaseOption(questions: Questions, id: string): void { + let entry = questions.open.get(id); + if (entry?.claim === "option") entry.claim = undefined; +} + +/** Hold a draft while its edit is fenced, so no submit reads uncommitted state. */ +export function reserveEdit(questions: Questions, id: string): boolean { + let entry = questions.open.get(id); + if (!entry || entry.claim) return false; + entry.claim = "edit"; + return true; +} + +export function releaseEdit(questions: Questions, id: string): void { + let entry = questions.open.get(id); + if (entry?.claim === "edit") entry.claim = undefined; +} diff --git a/apps/server/src/questions/store-reopen.test.ts b/apps/server/src/questions/store-reopen.test.ts new file mode 100644 index 00000000..92b28b0f --- /dev/null +++ b/apps/server/src/questions/store-reopen.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked, legacy } from "./store.test-fixtures"; + +// Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +describe("Store.reopen", () => { + it("reuses identity with a fresh draft, no Planner waiter, and no old tombstone", () => { + let questions = asked(); + let claim = Store.claimCancel(questions, "w", "ana"); + if (!claim.ok) throw new Error("setup"); + Store.commit(questions, claim.claim); + let definition = legacy(); + Store.reopen(questions, "w", definition, "w"); + let entry = Store.get(questions, "w")!; + expect(entry.definition).toEqual(definition); + expect(entry.revision).toBe(0); + expect(entry.settle).toBeUndefined(); + expect(questions.closed.has("w")).toBe(false); + expect(Question.read(entry.model, entry.definition).q1!.choice).toBeNull(); + expect(Question.read(entry.model, entry.definition).q2!.choice).toBeNull(); + expect(Store.addOption(questions, "w", "new", "Another")).toMatchObject({ + ok: false, + reason: "closed", + }); + expect(() => Store.reopen(questions, "w", definition, "w")).toThrow(/already open/); + }); +}); diff --git a/apps/server/src/questions/store-restore.test.ts b/apps/server/src/questions/store-restore.test.ts new file mode 100644 index 00000000..ac90e2be --- /dev/null +++ b/apps/server/src/questions/store-restore.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; + +import { legacy } from "./store.test-fixtures"; + +// Original restoration callback: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. + +describe("Store.restore shared definitions", () => { + it("restores a legacy multi-question draft with its original question IDs", () => { + let definition = legacy(); + let restored = Store.restore([{ + id: "w", + definition, + widget: "w", + model: [...Question.create(definition).toBinary()], + revision: 3, + }]); + expect(Store.snapshot(restored, "w")).toMatchObject({ + open: true, + definition, + revision: 3, + }); + expect(Store.outstanding(restored)[0]?.definition).toEqual(definition); + }); + it("restores an empty pending card without replacing its durable ID or draft mode", () => { + let definition = { + questions: [{ + id: "pending-question", + header: "Choice", + question: "Which option?", + multiple: false, + options: [], + }], + }; + let stored = { + id: "pending-card", + definition, + model: [...Question.create(definition).toBinary()], + revision: 7, + }; + let restored = Store.restore([stored]); + let entry = Store.get(restored, "pending-card")!; + expect(entry.definition).toEqual(definition); + expect(Question.read(entry.model, entry.definition)).toEqual({ + "pending-question": { mode: "choices", choice: null, custom: "", options: {} }, + }); + expect(Store.dump(restored)).toEqual([stored]); + }); + + it("rejects malformed stored definitions instead of only checking their question count", () => { + let definition = { questions: [legacy().questions[0]!] }; + let model = [...Question.create(definition).toBinary()]; + for ( + let invalid of [ + { ...definition, extra: true }, + { questions: [{ ...definition.questions[0]!, multiple: "false" }] }, + ] + ) { + expect(() => + Store.restore([{ + id: "invalid", + definition: invalid as Question.Definition, + model, + revision: 0, + }]) + ).toThrow(Question.QuestionError); + } + }); +}); diff --git a/apps/server/src/questions/store-retitle.test.ts b/apps/server/src/questions/store-retitle.test.ts new file mode 100644 index 00000000..58071850 --- /dev/null +++ b/apps/server/src/questions/store-retitle.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +describe("Store.retitle", () => { + it("changes one decision question without changing the human draft or option identities", () => { + let questions = asked(); + let entry = Store.get(questions, "w")!; + let chosen = Question.crdt.Model.fromBinary(entry.model.toBinary()) + .fork() as unknown as Question.Model; + chosen.api.val(["q", "choice"]).set("a"); + let patch = chosen.api.flush(); + if (!patch) throw new Error("selection made no patch"); + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")).toMatchObject({ + open: true, + accepted: true, + }); + entry = Store.get(questions, "w")!; + let before = [...entry.model.toBinary()]; + let result = Store.retitle(questions, "w", " Which auth system should ship first? "); + expect(result).toMatchObject({ ok: true, applied: true, revision: 2 }); + entry = Store.get(questions, "w")!; + expect(entry.definition.questions[0]?.question).toBe("Which auth system should ship first?"); + expect(entry.definition.questions[0]?.options).toEqual([ + { id: "a", label: "Auth0", description: "" }, + ]); + expect([...entry.model.toBinary()]).toEqual(before); + expect(Question.read(entry.model, entry.definition).q?.choice).toBe("a"); + expect(Store.restore(Store.dump(questions)).open.get("w")?.revision).toBe(2); + }); + + it("keeps an advisory suggestion tied to the new revision and avoids a repeat bump", () => { + let questions = asked(); + Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] }); + expect(Store.retitle(questions, "w", "Why use Auth0?")).toMatchObject({ + ok: true, + applied: true, + revision: 2, + }); + expect(Store.get(questions, "w")?.suggested).toEqual({ + optionId: "a", + messageIds: ["m1"], + revision: 2, + }); + expect(Store.retitle(questions, "w", " Why use Auth0? ")).toMatchObject({ + ok: true, + applied: false, + revision: 2, + }); + expect(Store.restore(Store.dump(questions)).open.get("w")?.suggested?.revision).toBe(2); + }); + + it("refuses invalid text, a resolving decision, and a closed decision", () => { + let questions = asked(); + expect(Store.retitle(questions, "w", " ")).toMatchObject({ + ok: false, + reason: "invalid", + }); + expect(Store.retitle(questions, "w", "x".repeat(1_001))).toMatchObject({ + ok: false, + reason: "invalid", + }); + let claim = Store.claimCancel(questions, "w", "ana"); + expect(Store.retitle(questions, "w", "Why now?")).toMatchObject({ + ok: false, + reason: "closed", + }); + if (!claim.ok) throw new Error("setup"); + Store.commit(questions, claim.claim); + expect(Store.retitle(questions, "w", "Why now?")).toMatchObject({ + ok: false, + reason: "closed", + }); + }); +}); diff --git a/apps/server/src/questions/store-suggestion-queue.test.ts b/apps/server/src/questions/store-suggestion-queue.test.ts new file mode 100644 index 00000000..957f84fd --- /dev/null +++ b/apps/server/src/questions/store-suggestion-queue.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Exact archive callbacks: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. +describe("Store.suggest queue", () => { + it("applies a human selection prepared before the server suggestion", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + let before = Store.get(questions, "w")!; + before.model = before.model.fork(65_537); + let human = Question.crdt.Model.fromBinary(before.model.toBinary()) + .fork(65_536) as unknown as Question.Model; + human.api.val(["q", "mode"]).set("choices"); + human.api.val(["q", "choice"]).set("b"); + let patch = human.api.flush(); + if (!patch) throw new Error("selection made no patch"); + expect(Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] })) + .toMatchObject({ ok: true }); + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")).toMatchObject({ + open: true, + accepted: true, + applied: true, + }); + let entry = Store.get(questions, "w")!; + expect(Question.read(entry.model, entry.definition).q!.choice).toBe("b"); + expect(entry.suggested).toBeUndefined(); + expect(entry.editors).toContain("ana"); + }); + + it("keeps a delayed human selection after several changed server suggestions", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + let entry = Store.get(questions, "w")!; + entry.model = entry.model.fork(65_537); + let human = Question.crdt.Model.fromBinary(entry.model.toBinary()) + .fork(65_536) as unknown as Question.Model; + human.api.val(["q", "mode"]).set("choices"); + human.api.val(["q", "choice"]).set("b"); + let patch = human.api.flush(); + if (!patch) throw new Error("selection made no patch"); + for (let [index, optionId] of ["a", "b", "a", "b", "a"].entries()) { + expect(Store.suggest(questions, "w", { optionId, messageIds: [`m${index}`] })) + .toMatchObject({ ok: true }); + } + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")) + .toMatchObject({ open: true, accepted: true, applied: true }); + expect(Question.read(entry.model, entry.definition).q!.choice).toBe("b"); + expect(entry.suggested).toBeUndefined(); + }); + + it("keeps two queued human choices and converges peers after repeated suggestions", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + Store.addOption(questions, "w", "c", "Own service"); + let entry = Store.get(questions, "w")!; + entry.model = entry.model.fork(65_537); + let human = Question.crdt.Model.fromBinary(entry.model.toBinary()) + .fork(65_536) as unknown as Question.Model; + let peer = Question.crdt.Model.fromBinary(entry.model.toBinary()) + .fork(65_538) as unknown as Question.Model; + human.api.val(["q", "mode"]).set("choices"); + human.api.val(["q", "choice"]).set("b"); + let first = human.api.flush(); + human.api.val(["q", "mode"]).set("choices"); + human.api.val(["q", "choice"]).set("c"); + let second = human.api.flush(); + if (!first || !second) throw new Error("selections made no patches"); + for (let [index, optionId] of ["a", "b", "a", "b", "a"].entries()) { + let suggestion = Store.suggest(questions, "w", { + optionId, + messageIds: [`m${index}`], + }); + if (!suggestion.ok) throw new Error("suggestion was refused"); + expect(suggestion.patch).toEqual([]); + } + for (let patch of [first, second]) { + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")) + .toMatchObject({ open: true, accepted: true, applied: true }); + peer.applyPatch(patch); + } + expect(Question.read(entry.model, entry.definition).q!.choice).toBe("c"); + expect(Question.read(human, entry.definition).q!.choice).toBe("c"); + expect(Question.read(peer, entry.definition).q!.choice).toBe("c"); + }); +}); diff --git a/apps/server/src/questions/store-suggestion-state.test.ts b/apps/server/src/questions/store-suggestion-state.test.ts new file mode 100644 index 00000000..93b478f4 --- /dev/null +++ b/apps/server/src/questions/store-suggestion-state.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Exact archive callbacks: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. +describe("Store.suggest state", () => { + it("retains a known suggestion without changing the human-owned draft", () => { + let questions = asked(); + let result = Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] }); + expect(result).toMatchObject({ ok: true, revision: 1 }); + let entry = Store.get(questions, "w")!; + expect(Question.read(entry.model, entry.definition).q!.choice).toBeNull(); + expect(entry.suggested).toEqual({ optionId: "a", messageIds: ["m1"], revision: 1 }); + expect(Store.restore(Store.dump(questions)).open.get("w")!.suggested).toEqual({ + optionId: "a", + messageIds: ["m1"], + revision: 1, + }); + }); + + it("replaying the same suggestion is a no-op, while new evidence can move it", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + expect(Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] })) + .toMatchObject({ ok: true, revision: 2 }); + expect(Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] })) + .toEqual({ ok: true, patch: [], revision: 2 }); + expect(Store.suggest(questions, "w", { optionId: "b", messageIds: ["m2"] })) + .toMatchObject({ ok: true, revision: 3 }); + expect(Store.get(questions, "w")!.suggested).toEqual({ + optionId: "b", + messageIds: ["m2"], + revision: 3, + }); + }); + + it("does not replace a human selection and clears a suggestion only after an applied edit", () => { + let questions = asked(); + expect(Store.addOption(questions, "w", "b", "GitHub Apps")).toMatchObject({ ok: true }); + let entry = Store.get(questions, "w")!; + let chosen = entry.model.clone(); + chosen.api.val(["q", "choice"]).set("b"); + let patch = chosen.api.flush(); + if (!patch) throw new Error("selection made no patch"); + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")).toMatchObject({ + accepted: true, + applied: true, + }); + expect(Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] })) + .toEqual({ ok: false, reason: "chosen" }); + expect(Question.read(entry.model, entry.definition).q!.choice).toBe("b"); + + let fresh = asked(); + Store.addOption(fresh, "w", "b", "GitHub Apps"); + Store.suggest(fresh, "w", { optionId: "a", messageIds: ["m1"] }); + let suggested = Store.get(fresh, "w")!; + let human = suggested.model.clone(); + human.api.val(["q", "choice"]).set("b"); + let humanPatch = human.api.flush(); + if (!humanPatch) throw new Error("selection made no patch"); + expect(Store.edit(fresh, "w", [...humanPatch.toBinary()], "ben")).toMatchObject({ + accepted: true, + applied: true, + }); + expect(Store.get(fresh, "w")!.suggested).toBeUndefined(); + }); + + it("rejects unknown options, closed cards, and malformed stored sources", () => { + let questions = asked(); + expect(Store.suggest(questions, "w", { optionId: "missing", messageIds: [] })) + .toEqual({ ok: false, reason: "unknown" }); + expect(Store.suggest(questions, "missing", { optionId: "a", messageIds: [] })) + .toEqual({ ok: false, reason: "closed" }); + expect(Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] })) + .toMatchObject({ ok: true }); + let stored = Store.dump(questions)[0]!; + expect(() => + Store.restore([{ + ...stored, + suggested: { + ...stored.suggested!, + optionId: "missing", + }, + }]) + ) + .toThrow(/suggestion/); + expect(() => + Store.restore([{ + ...stored, + suggested: { ...stored.suggested!, messageIds: ["x".repeat(201)] }, + }]) + ).toThrow(/suggestion/); + expect(() => + Store.restore([{ + ...stored, + suggested: { + ...stored.suggested!, + revision: 0, + }, + }]) + ).toThrow(/suggestion/); + let chosen = Store.get(questions, "w")!.model.clone(); + chosen.api.val(["q", "choice"]).set("a"); + expect(() => + Store.restore([{ + ...stored, + model: [...chosen.toBinary()], + }]) + ).toThrow(/suggestion/); + expect(() => + Store.restore([{ + ...stored, + suggested: { ...stored.suggested!, extra: true }, + } as never]) + ).toThrow(/suggestion/); + }); +}); diff --git a/apps/server/src/questions/store-suggestion-submit.test.ts b/apps/server/src/questions/store-suggestion-submit.test.ts new file mode 100644 index 00000000..d32b1e4c --- /dev/null +++ b/apps/server/src/questions/store-suggestion-submit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; + +import * as Store from "./store"; +import { asked } from "./store.test-fixtures"; + +// Exact archive callbacks: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. +describe("Store.suggest submit", () => { + it("claims only the current visible suggestion while the draft is untouched", () => { + let questions = asked(); + Store.addOption(questions, "w", "b", "GitHub Apps"); + let first = Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] }); + if (!first.ok) throw new Error("suggestion was refused"); + expect(Store.claimSubmit(questions, "w", first.revision, "ana")).toMatchObject({ + ok: false, + reason: "invalid", + }); + let moved = Store.suggest(questions, "w", { optionId: "b", messageIds: ["m2"] }); + if (!moved.ok) throw new Error("suggestion was refused"); + expect(Store.claimSubmit(questions, "w", first.revision, "ana", "a")).toEqual({ + ok: false, + reason: "stale", + current: moved.revision, + }); + expect(Store.claimSubmit(questions, "w", moved.revision, "ana", "a")).toMatchObject({ + ok: false, + reason: "invalid", + }); + let claim = Store.claimSubmit(questions, "w", moved.revision, "ana", "b"); + if (!claim.ok) throw new Error("current suggestion was refused"); + expect(claim.answers).toEqual([{ + question: "What auth system should we use?", + choices: ["GitHub Apps"], + optionIds: ["b"], + }]); + Store.rollback(questions, claim.claim); + }); + + it("refuses a suggestion fallback after a human draft edit", () => { + let questions = asked(); + let suggestion = Store.suggest(questions, "w", { optionId: "a", messageIds: ["m1"] }); + if (!suggestion.ok) throw new Error("suggestion was refused"); + let entry = Store.get(questions, "w")!; + let human = entry.model.fork(); + human.api.val(["q", "mode"]).set("custom"); + let patch = human.api.flush(); + if (!patch) throw new Error("human edit made no patch"); + expect(Store.edit(questions, "w", [...patch.toBinary()], "ana")).toMatchObject({ + accepted: true, + applied: true, + }); + expect(entry.suggested).toBeUndefined(); + expect(Store.claimSubmit(questions, "w", entry.revision, "ana", "a")).toMatchObject({ + ok: false, + reason: "invalid", + }); + }); +}); diff --git a/apps/server/src/questions/store-suggestions.ts b/apps/server/src/questions/store-suggestions.ts new file mode 100644 index 00000000..60300288 --- /dev/null +++ b/apps/server/src/questions/store-suggestions.ts @@ -0,0 +1,50 @@ +import * as Question from "@chopin/question"; + +import type { Draft } from "@chopin/question"; +import type { Questions } from "./store-types"; + +export function untouched(draft: Draft): boolean { + return draft.mode === "choices" && draft.choice === null && draft.custom.length === 0 + && !Object.values(draft.options).some(Boolean); +} + +/** Persist or clear an advisory conversation option without editing the shared draft. */ +export function suggest( + questions: Questions, + id: string, + suggestion?: { optionId: string; messageIds: string[] }, +): { ok: true; patch: number[]; revision: number } | { + ok: false; + reason: "closed" | "chosen" | "unknown"; +} { + let entry = questions.open.get(id); + if (!entry || entry.claim) return { ok: false, reason: "closed" }; + if (!suggestion) { + if (!entry.suggested) return { ok: true, patch: [], revision: entry.revision }; + entry.revision++; + entry.suggested = undefined; + return { ok: true, patch: [], revision: entry.revision }; + } + let question = entry.definition.questions[0]; + if ( + entry.definition.questions.length !== 1 || !question || question.multiple + || !question.options.some(option => option.id === suggestion.optionId) + || !Array.isArray(suggestion.messageIds) || suggestion.messageIds.length > 16 + || suggestion.messageIds.some(id => typeof id !== "string" || !id || id.length > 200) + || new Set(suggestion.messageIds).size !== suggestion.messageIds.length + ) return { ok: false, reason: "unknown" }; + let draft = Question.read(entry.model, entry.definition)[question.id]!; + if (!untouched(draft)) return { ok: false, reason: "chosen" }; + if ( + entry.suggested?.optionId === suggestion.optionId + && entry.suggested.messageIds.length === suggestion.messageIds.length + && entry.suggested.messageIds.every((id, index) => id === suggestion.messageIds[index]) + ) return { ok: true, patch: [], revision: entry.revision }; + entry.revision++; + entry.suggested = { + optionId: suggestion.optionId, + messageIds: [...suggestion.messageIds], + revision: entry.revision, + }; + return { ok: true, patch: [], revision: entry.revision }; +} diff --git a/apps/server/src/questions/store-types.ts b/apps/server/src/questions/store-types.ts new file mode 100644 index 00000000..b821f11d --- /dev/null +++ b/apps/server/src/questions/store-types.ts @@ -0,0 +1,59 @@ +import type { Answer, Definition, Model } from "@chopin/question"; + +export type Collaborator = { + client: string; + handle: string; + question?: string; + field?: "choices" | "custom"; +}; + +export type Ended = + | { status: "answered"; answers: Answer[]; resolver: string } + | { status: "cancelled"; resolver: string }; + +export type Open = { + id: string; + definition: Definition; + /** The plan node this belongs to, when it has one. */ + widget?: string; + model: Model; + revision: number; + presence: Map; + /** Handles whose edits were accepted, in first-edit order. */ + editors: Set; + /** An advisory chat pre-selection; the draft remains human-owned. */ + suggested?: { optionId: string; messageIds: string[]; revision: number }; + /** Set while a resolution is in flight; blocks edits and rival claims. */ + claim?: "submit" | "cancel" | "option" | "edit"; + /** Resolves the promise the agent is waiting on. */ + settle?: (ended: Ended) => void; +}; + +export type Closed = { result: Ended; revision: number; expires: number }; + +export type Claim = { + id: string; + entry: Open; + result: Ended; +}; + +export type Questions = { + open: Map; + /** + * Tombstones. + * + * A submit that arrives just after somebody else's would otherwise be told + * the questionnaire never existed, which reads as an error rather than as + * "they got there first". + */ + closed: Map; +}; + +export type StoredOpen = { + id: string; + definition: Definition; + widget?: string; + model: number[]; + revision: number; + suggested?: { optionId: string; messageIds: string[]; revision: number }; +}; diff --git a/apps/server/src/questions/store.test-fixtures.ts b/apps/server/src/questions/store.test-fixtures.ts new file mode 100644 index 00000000..a88e2f0a --- /dev/null +++ b/apps/server/src/questions/store.test-fixtures.ts @@ -0,0 +1,32 @@ +import * as Question from "@chopin/question"; + +import * as Store from "./store"; + +// Exact archive helpers: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. +export function asked() { + let questions = Store.create(); + let definition = Question.decision({ + questions: [{ + id: "q", + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: [{ id: "a", label: "Auth0", description: "" }], + }], + }); + void Store.ask(questions, "w", definition, "w"); + return questions; +} + +export function legacy() { + return { + questions: ["q1", "q2"].map(id => ({ + id, + header: id, + question: `${id}?`, + multiple: false, + options: [{ id: `${id}-a`, label: "One", description: "" }], + })), + }; +} diff --git a/apps/server/src/questions/store.ts b/apps/server/src/questions/store.ts index 32069839..92dc00b2 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -16,64 +16,38 @@ import * as Question from "@chopin/question"; -import type { Answer, DecisionDefinition, Definition, Drafts, Model } from "@chopin/question"; +import { untouched } from "./store-suggestions"; + +import type { Answer, DecisionDefinition, Definition, Drafts } from "@chopin/question"; +import type { + Claim, + Closed, + Collaborator, + Ended, + Open, + Questions, + StoredOpen, +} from "./store-types"; + +export { reopen } from "./store-lifecycle"; +export { + addOption, + before, + relabelOption, + releaseEdit, + releaseOption, + reserveEdit, + reserveOption, + retitle, + revert, +} from "./store-options"; +export type { AddedOption, Before, Retitled } from "./store-options"; +export { suggest } from "./store-suggestions"; +export type { Claim, Collaborator, Ended, Questions, StoredOpen } from "./store-types"; /** How long a resolved questionnaire is remembered, for late arrivals. */ const CLOSED_TTL = 5 * 60 * 1_000; -export type Collaborator = { - client: string; - handle: string; - question?: string; - field?: "choices" | "custom"; -}; - -export type Ended = - | { status: "answered"; answers: Answer[]; resolver: string } - | { status: "cancelled"; resolver: string }; - -type Open = { - id: string; - definition: DecisionDefinition; - /** The plan node this belongs to, when it has one. */ - widget?: string; - model: Model; - revision: number; - presence: Map; - /** Set while a resolution is in flight; blocks edits and rival claims. */ - claim?: "submit" | "cancel"; - /** Resolves the promise the agent is waiting on. */ - settle?: (ended: Ended) => void; -}; - -type Closed = { result: Ended; revision: number; expires: number }; - -export type Claim = { - id: string; - entry: Open; - result: Ended; -}; - -export type Questions = { - open: Map; - /** - * Tombstones. - * - * A submit that arrives just after somebody else's would otherwise be told - * the questionnaire never existed, which reads as an error rather than as - * "they got there first". - */ - closed: Map; -}; - -export type StoredOpen = { - id: string; - definition: DecisionDefinition; - widget?: string; - model: number[]; - revision: number; -}; - export function create(): Questions { return { open: new Map(), closed: new Map() }; } @@ -85,6 +59,7 @@ export function dump(questions: Questions): StoredOpen[] { ...(entry.widget ? { widget: entry.widget } : {}), model: [...entry.model.toBinary()], revision: entry.revision, + ...(entry.suggested ? { suggested: entry.suggested } : {}), })); } @@ -99,14 +74,36 @@ export function restore(entries: StoredOpen[]): Questions { !Array.isArray(entry.model) || entry.model.some(value => !Number.isInteger(value) || value < 0 || value > 255) ) Question.reject("Questionnaire model is invalid"); - let definition = Question.decision(entry.definition); + let definition = Question.identified(entry.definition); + if (entry.suggested !== undefined) { + let suggested = entry.suggested; + if ( + !suggested || typeof suggested !== "object" || Array.isArray(suggested) + || Object.keys(suggested).sort().join(",") !== "messageIds,optionId,revision" + || typeof suggested.optionId !== "string" + || !Number.isSafeInteger(suggested.revision) || suggested.revision < 1 + || suggested.revision !== entry.revision + || !definition.questions[0]?.options.some(option => option.id === suggested.optionId) + || definition.questions.length !== 1 || definition.questions[0]?.multiple + || !Array.isArray(suggested.messageIds) || suggested.messageIds.length > 16 + || suggested.messageIds.some(id => typeof id !== "string" || !id || id.length > 200) + || new Set(suggested.messageIds).size !== suggested.messageIds.length + ) Question.reject("Questionnaire suggestion is invalid"); + } + let model = Question.restore(entry.model, definition); + if (entry.suggested) { + let draft = Question.read(model, definition)[definition.questions[0]!.id]!; + if (!untouched(draft)) Question.reject("Questionnaire suggestion is invalid"); + } questions.open.set(entry.id, { id: entry.id, definition, ...(entry.widget ? { widget: entry.widget } : {}), - model: Question.restore(entry.model, definition), + model, revision: entry.revision, + ...(entry.suggested ? { suggested: entry.suggested } : {}), presence: new Map(), + editors: new Set(), }); } return questions; @@ -154,6 +151,7 @@ export function ask( model, revision: 0, presence: new Map(), + editors: new Set(), settle, }); }); @@ -173,7 +171,7 @@ export function redefine( questions: Questions, id: string, definition: DecisionDefinition, -): { ok: true; previous: DecisionDefinition } | { ok: false; reason: "resolved" | "resolving" } { +): { ok: true; previous: Definition } | { ok: false; reason: "resolved" | "resolving" } { let entry = questions.open.get(id); if (!entry) return { ok: false, reason: "resolved" }; if (entry.claim) return { ok: false, reason: "resolving" }; @@ -186,7 +184,7 @@ export function redefine( export function restoreDefinition( questions: Questions, id: string, - definition: DecisionDefinition, + definition: Definition, ): void { let entry = questions.open.get(id); if (entry) entry.definition = definition; @@ -195,7 +193,7 @@ export function restoreDefinition( /** Everything still open, for a client that has just joined. */ export function outstanding( questions: Questions, -): Array<{ id: string; definition: DecisionDefinition; widget?: string }> { +): Array<{ id: string; definition: Definition; widget?: string }> { return [...questions.open.values()].map(entry => ({ id: entry.id, definition: entry.definition, @@ -206,7 +204,7 @@ export function outstanding( export type Opened = | { open: true; - definition: DecisionDefinition; + definition: Definition; model: number[]; revision: number; presence: Collaborator[]; @@ -237,7 +235,7 @@ export type Edited = * `Question.apply`. What is decided here is who may ask: a questionnaire that * is resolving takes no more edits, because its answer has already been read. */ -export function edit(questions: Questions, id: string, binary: number[]): Edited { +export function edit(questions: Questions, id: string, binary: number[], editor?: string): Edited { let entry = questions.open.get(id); if (!entry) { let ended = questions.closed.get(id); @@ -263,6 +261,8 @@ export function edit(questions: Questions, id: string, binary: number[]): Edited entry.model = outcome.model; entry.revision++; + entry.suggested = undefined; + if (editor) entry.editors.add(editor); return { open: true, accepted: true, applied: true, revision: entry.revision }; } @@ -333,6 +333,7 @@ export function claimSubmit( id: string, revision: number, resolver: string, + suggestedOptionId?: string, ): { ok: true; claim: Claim; answers: Answer[]; widget?: string } | SubmitRefusal { let ended = questions.closed.get(id); if (ended) return resolved(ended); @@ -355,6 +356,17 @@ export function claimSubmit( }; } + if (suggestedOptionId !== undefined) { + let question = entry.definition.questions[0]; + let draft = question && drafts[question.id]; + if ( + entry.definition.questions.length !== 1 || !question || question.multiple || !draft + || !untouched(draft) || !entry.suggested + || entry.suggested.optionId !== suggestedOptionId + || !question.options.some(option => option.id === suggestedOptionId) + ) return { ok: false, reason: "invalid", message: "Suggestion is no longer current" }; + drafts = { ...drafts, [question.id]: { ...draft, choice: suggestedOptionId } }; + } let outcome = Question.derive(entry.definition, drafts); if (!outcome.ok) return { ok: false, reason: "invalid", message: outcome.message }; diff --git a/apps/server/src/questions/write-prose.test.ts b/apps/server/src/questions/write-prose.test.ts new file mode 100644 index 00000000..97867ad1 --- /dev/null +++ b/apps/server/src/questions/write-prose.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { $createParagraphNode, $getRoot } from "lexical"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { normalizeRecord } from "./service"; +import { proseOperation, validateProse } from "./write-prose"; + +const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; +const WIDGET = ` + + + +`; + +let opened: Service.Plan[] = []; +afterEach(async () => { + for (let plan of opened) await Service.close(plan); + opened = []; +}); + +async function decided(source = `Context.\n\n${WIDGET}`, fields: { + status?: "answered" | "open"; + origin?: "conversation" | "planner"; + proseAt?: number; + orphaned?: boolean; +} = {}) { + let { plan } = await openPlan(source); + opened.push(plan); + let hashes = room.digests(plan.document); + let prose = fields.proseAt !== undefined + ? [room.anchorAt(plan.document, fields.proseAt, hashes[fields.proseAt]!)] + : fields.orphaned + ? [{ ...room.anchorAt(plan.document, 0, hashes[0]!), orphaned: true }] + : undefined; + plan.records.set( + CARD, + normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: fields.status ?? "answered", + origin: fields.origin ?? "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + ...(prose ? { prose } : {}), + }), + ); + return plan; +} + +describe("validateProse", () => { + test("accepts one ordinary paragraph with inline formatting", () => { + expect(validateProse(" We chose **GitHub Apps** with `org` access. ")).toEqual({ + ok: true, + text: "We chose **GitHub Apps** with `org` access.", + }); + }); + + test("refuses empty, oversized, multiblock, and non-prose constructs", () => { + for ( + let value of [ + "", + "x".repeat(601), + "One.\n\nTwo.", + "# Heading", + "- List", + "Hi", + "Text ", + "![image](x)", + ] + ) { + expect(validateProse(value).ok).toBe(false); + } + }); +}); + +describe("proseOperation", () => { + test("inserts before the first decision in canonical coordinates", async () => { + let plan = await decided(WIDGET); + expect(room.questionnaireIndex(plan.document, CARD)).toBe(0); + expect(proseOperation(plan, CARD, "We chose GitHub Apps.")).toEqual({ + ok: true, + mode: "insert", + index: 0, + steps: [ + [{ op: "insert", index: 0, source: "We chose GitHub Apps.\n" }], + [{ op: "move", index: 0, to: 1 }], + ], + }); + }); + + test("ignores leading and inter-card caret paragraphs when addressing cards", async () => { + let plan = await decided(); + plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore($createParagraphNode()); + children.at(-1)!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + expect(room.digests(plan.document)).toHaveLength(2); + expect(room.questionnaireIndex(plan.document, CARD)).toBe(1); + expect(proseOperation(plan, CARD, "Decision prose.")).toEqual({ + ok: true, + mode: "insert", + index: 1, + steps: [[{ op: "insert", index: 0, source: "Decision prose.\n" }]], + }); + }); + + test("replaces only the paragraph named by a live anchor beside identical text", async () => { + let plan = await decided(`Same.\n\nSame.\n\n${WIDGET}`, { proseAt: 1 }); + expect(proseOperation(plan, CARD, "New prose.")).toEqual({ + ok: true, + mode: "replace", + index: 1, + steps: [[{ op: "replace", index: 1, source: "New prose.\n" }]], + }); + }); + + test("inserts when prose is orphaned, but refuses an unresolved live anchor", async () => { + let orphan = await decided(undefined, { orphaned: true }); + expect(proseOperation(orphan, CARD, "New prose.")).toMatchObject({ + ok: true, + mode: "insert", + }); + let live = await decided(`Old prose.\n\n${WIDGET}`, { proseAt: 0 }); + live.records.get(CARD)!.prose![0]!.epoch = "different-epoch"; + expect(proseOperation(live, CARD, "New prose.").ok).toBe(false); + }); + + test("refuses open and Planner-owned cards", async () => { + let open = await decided(undefined, { status: "open" }); + let planner = await decided(undefined, { origin: "planner" }); + expect(proseOperation(open, CARD, "Text.").ok).toBe(false); + expect(proseOperation(planner, CARD, "Text.").ok).toBe(false); + }); +}); diff --git a/apps/server/src/questions/write-prose.ts b/apps/server/src/questions/write-prose.ts new file mode 100644 index 00000000..8892ccde --- /dev/null +++ b/apps/server/src/questions/write-prose.ts @@ -0,0 +1,91 @@ +/** Validate and place a decided card's single paragraph in canonical document coordinates. */ + +import { parse } from "@chopin/dialect"; + +import * as room from "../plan/room"; + +import type * as edit from "../plan/edit"; +import type { Plan } from "../plan/service"; + +export const MAX_PROSE = 600; + +export type ProsePlan = + | { ok: true; steps: edit.Operation[][]; index: number; mode: "insert" | "replace" } + | { ok: false; message: string }; + +function inline(node: { type: string; children?: unknown[] }): boolean { + if (node.type === "text" || node.type === "inlineCode" || node.type === "break") return true; + if (!["strong", "emphasis", "delete", "link"].includes(node.type)) return false; + return Array.isArray(node.children) + && node.children.every(child => inline(child as { type: string; children?: unknown[] })); +} + +export function validateProse( + raw: unknown, +): { ok: true; text: string } | { ok: false; message: string } { + if (typeof raw !== "string") return { ok: false, message: "text must be a string" }; + let text = raw.trim(); + if (!text) return { ok: false, message: "text must not be empty" }; + if (text.length > MAX_PROSE) { + return { ok: false, message: `text is at most ${MAX_PROSE} characters` }; + } + try { + let blocks = parse(`${text}\n`).children; + if (blocks.length !== 1 || blocks[0]?.type !== "paragraph") { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + if (!blocks[0].children.every(child => inline(child))) { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + } catch { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + return { ok: true, text }; +} + +export function proseOperation(plan: Plan, id: string, text: string): ProsePlan { + let record = plan.records.get(id); + if (!record) return { ok: false, message: `no decision ${id}` }; + if (record.origin !== "conversation") { + return { ok: false, message: "only conversation decisions are written up" }; + } + if (record.status !== "answered" || !record.owner || !record.threadId) { + return { ok: false, message: "this decision is not decided by a person" }; + } + let card = room.questionnaireIndex(plan.document, id); + if (card === undefined) { + return { ok: false, message: "the decision card is not in the document" }; + } + let source = `${text}\n`; + let anchors = record.prose?.filter(anchor => !anchor.orphaned) ?? []; + if (anchors.length > 1) return { ok: false, message: "decision prose is ambiguous" }; + if (anchors.length === 1) { + let anchor = anchors[0]!; + let matches = room.digests(plan.document).flatMap((_, index) => + room.matchesAnchor(plan.document, anchor, index) ? [index] : [] + ); + if (matches.length !== 1) { + return { ok: false, message: "decision prose no longer has a unique live block" }; + } + let index = matches[0]!; + let blocks = parse(room.project(plan.document)).children; + if (blocks[index]?.type !== "paragraph") { + return { ok: false, message: "decision prose does not name a paragraph" }; + } + return { ok: true, mode: "replace", index, steps: [[{ op: "replace", index, source }]] }; + } + if (card > 0) { + return { + ok: true, + mode: "insert", + index: card, + steps: [[{ op: "insert", index: card - 1, source }]], + }; + } + return { + ok: true, + mode: "insert", + index: 0, + steps: [[{ op: "insert", index: 0, source }], [{ op: "move", index: 0, to: 1 }]], + }; +} diff --git a/apps/server/src/research/inline-1.test.ts b/apps/server/src/research/inline-1.test.ts new file mode 100644 index 00000000..be7c7137 --- /dev/null +++ b/apps/server/src/research/inline-1.test.ts @@ -0,0 +1,136 @@ +import { expect, it } from "bun:test"; +import { answerArtifact, evidenceArtifact, settle, setup } from "./test-support"; +import { acceptedOffer, terminalNotices } from "./inline.test-fixtures"; + +it("reads a pending, pre-job, then linked accepted request without starting work", async () => { + let context = await setup(); + let offer = acceptedOffer(context.userId); + expect(await context.service.acceptedOfferLink(context.channelId, offer)) + .toEqual({ status: "pending" }); + let placed = false; + let input = { + channelId: context.channelId, + question: offer.brief, + originMessageId: offer.source.messageId, + requestedBy: offer.action!.principalId, + requestedByHandle: offer.action!.actor.handle, + placeReference: async () => { + if (!placed) throw new Error("placement interrupted"); + return "placed" as const; + }, + }; + await expect(context.service.startPlannerInline(input)).rejects.toThrow( + "placement interrupted", + ); + let beforeJobs = await context.jobs.list(context.channelId, 100); + let beforePublications = context.publications.length; + let preJob = await context.restart().acceptedOfferLink(context.channelId, offer); + expect(preJob.status).toBe("unlinked"); + expect(typeof preJob.researchRequestId).toBe("string"); + if (!preJob.researchRequestId) throw new Error("pre-job request ID was not found"); + expect(await context.jobs.list(context.channelId, 100)).toEqual(beforeJobs); + expect(context.publications).toHaveLength(beforePublications); + placed = true; + let started = await context.restart().startPlannerInline(input); + expect(started.request.id).toBe(preJob.researchRequestId); + expect(await context.restart().acceptedOfferLink(context.channelId, offer)) + .toEqual({ status: "linked", researchRequestId: started.request.id }); + for (let index = 0; index < 105; index++) { + await context.storage.research.start({ + id: `other-${index}`, + channelId: context.channelId, + title: `Other ${index}`, + question: `Other question ${index}`, + origin: "planner", + originMessageId: `other-message-${index}`, + createdBy: context.userId, + turnId: `other-turn-${index}`, + messageId: `other-message-record-${index}`, + requestId: `other-request-${index}`, + idempotencyKey: `other-key-${index}`, + fingerprint: `other-fingerprint-${index}`, + now: context.advance(), + lease: context.lease, + }); + } + expect((await context.storage.research.list(context.channelId, 100, true)) + .some(item => item.id === started.request.id)).toBe(false); + expect(await context.restart().acceptedOfferLink(context.channelId, offer)) + .toEqual({ status: "linked", researchRequestId: started.request.id }); + expect(await context.service.acceptedOfferLink("other-channel", offer)) + .toEqual({ status: "pending" }); +}); + +it("rejects colliding or altered original consent identities", async () => { + let context = await setup(); + let offer = acceptedOffer(context.userId); + await context.service.startPlannerInline({ + channelId: context.channelId, + question: offer.brief, + originMessageId: offer.source.messageId, + requestedBy: context.userId, + requestedByHandle: "octocat", + placeReference: async () => "placed", + }); + for ( + let changed of [ + { ...offer, brief: "Different brief" }, + { ...offer, action: { ...offer.action!, principalId: "another-user" } }, + { + ...offer, + action: { + ...offer.action!, + actor: { kind: "member" as const, handle: "someone-else" }, + }, + }, + ] + ) { + await expect(context.service.acceptedOfferLink(context.channelId, changed)) + .rejects.toMatchObject({ code: "invalid-state" }); + } + await expect(context.service.acceptedOfferLink(context.channelId, { + ...offer, + status: "dismissed", + })).rejects.toMatchObject({ code: "invalid-request" }); + let ordinary = await setup(); + let ordinaryOffer = acceptedOffer(ordinary.userId); + await ordinary.service.startPlanner({ + channelId: ordinary.channelId, + question: ordinaryOffer.brief, + originMessageId: ordinaryOffer.source.messageId, + requestedBy: ordinary.userId, + requestedByHandle: "octocat", + }); + await expect(ordinary.service.acceptedOfferLink(ordinary.channelId, ordinaryOffer)) + .rejects.toMatchObject({ code: "invalid-state" }); +}); + +it("emits a stable ready notice after publishing a Planner inline child", async () => { + let context = await setup(); + let started = await context.service.startPlannerInline({ + channelId: context.channelId, + question: "Check the release", + originMessageId: "message-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"); + expect(notices).toEqual([{ + channelId: context.channelId, + id: `research-ready:${started.request.id}:${answer.job.id}`, + text: `Research is ready. [Open the research document](` + + `/documents/octo-org/score/${context.channel.slug}/children/${request.child.slug}).`, + }]); + await service.jobChanged(answer.job); + expect(notices.map(value => value.id)).toEqual([ + `research-ready:${started.request.id}:${answer.job.id}`, + `research-ready:${started.request.id}:${answer.job.id}`, + ]); +}); diff --git a/apps/server/src/research/inline-2.test.ts b/apps/server/src/research/inline-2.test.ts new file mode 100644 index 00000000..f87f8a3b --- /dev/null +++ b/apps/server/src/research/inline-2.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "bun:test"; +import { ResearchWorkspaceService } from "./service"; +import { + answerArtifact, + evidenceArtifact, + REPOSITORY_ID, + requestId, + settle, + setup, +} from "./test-support"; +import { failInitialEvidence, terminalNotices } from "./inline.test-fixtures"; + +it("emits a stable failed notice without exposing a request ID", async () => { + let context = await setup(); + let started = await context.service.startPlannerInline({ + channelId: context.channelId, + question: "Check the release", + originMessageId: "message-failed", + requestedBy: context.userId, + placeReference: async () => "placed", + }); + let failedJobId = await failInitialEvidence(context); + let failed = await context.jobs.get(context.channelId, failedJobId); + if (!failed) throw new Error("failed research job is missing"); + let { service, notices } = terminalNotices(context); + await service.jobChanged(failed.job); + expect((await service.request(context.channelId, started.request.id))?.stage).toBe("failed"); + expect(notices).toEqual([{ + channelId: context.channelId, + id: `research-failed:${started.request.id}:${failedJobId}`, + text: "Research could not be completed. You can retry it from the research card.", + }]); + expect(notices[0]!.text).not.toContain(started.request.id); + await service.jobChanged(failed.job); + expect(notices[1]).toEqual(notices[0]); +}); + +it("recovers missed ready and failed notices across repeated startup scans", async () => { + let context = await setup(); + let ready = await context.service.startPlannerInline({ + channelId: context.channelId, + question: "Check the release", + originMessageId: "missed-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); + await context.service.jobChanged(answer.job); + let failed = await context.service.startPlannerInline({ + channelId: context.channelId, + question: "Check the failure", + originMessageId: "missed-failed", + requestedBy: context.userId, + placeReference: async () => "placed", + }); + let failedJobId = await failInitialEvidence(context); + let notices = new Map(); + let recovered = new ResearchWorkspaceService({ + storage: context.storage, + jobs: context.jobs, + lease: () => context.lease, + current: async () => undefined, + publish: () => {}, + terminalNotice: async (_channelId, id, text) => { + let previous = notices.get(id); + if (previous && previous !== text) throw new Error("conflicting notice"); + notices.set(id, text); + }, + }); + await recovered.recoverTerminalPlannerInline(); + expect([...notices.keys()]).toEqual([ + `research-ready:${ready.request.id}:${answer.job.id}`, + `research-failed:${failed.request.id}:${failedJobId}`, + ]); + await recovered.recoverTerminalPlannerInline(); + expect(notices.size).toBe(2); +}); + +it("does not announce terminal legacy research without an inline card", async () => { + let context = await setup(); + await context.service.start({ + channelId: context.channelId, + question: "Old research", + requestId: requestId(75), + requestedBy: context.userId, + }); + let failedJobId = await failInitialEvidence(context); + let failed = await context.jobs.get(context.channelId, failedJobId); + if (!failed) throw new Error("failed research job is missing"); + let { service, notices } = terminalNotices(context); + await service.jobChanged(failed.job); + expect(notices).toEqual([]); +}); + +it("places one inline Planner request before queueing or publishing it", async () => { + let context = await setup(); + let references = new Set(); + let placements = 0; + let input = { + channelId: context.channelId, + question: "Which public evidence supports version 3?", + originMessageId: "01K39QZG000000000000000003", + requestedBy: context.userId, + placeReference: async (id: string) => { + placements++; + if (placements === 1) { + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + expect(context.publications).toEqual([]); + expect( + (await context.storage.research.get(context.channelId, id))?.workspace + .inlineReference, + ).toBe("pending"); + } + references.add(id); + return "placed" as const; + }, + }; + let started = await context.service.startPlannerInline(input); + expect(references.has(started.request.id)).toBe(true); + expect(started.request).toMatchObject({ state: "pending", stage: "queued" }); + expect((await context.storage.research.get(context.channelId, started.request.id))?.workspace) + .toMatchObject({ origin: "planner", originMessageId: input.originMessageId }); + expect(context.publications).toHaveLength(1); + expect(await context.service.request(context.channelId, started.request.id)) + .toMatchObject({ id: started.request.id, stage: "queued" }); + expect(await context.service.get(context.channelId, started.request.id)).toBeUndefined(); + expect(await context.service.list(context.channelId)).toEqual([]); + expect((await context.service.listRepository(REPOSITORY_ID)).channels).toEqual([]); + let replay = await context.service.startPlannerInline(input); + expect(replay).toEqual({ ...started, repeated: true }); + expect(placements).toBe(2); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(1); +}); diff --git a/apps/server/src/research/inline-3.test.ts b/apps/server/src/research/inline-3.test.ts new file mode 100644 index 00000000..f21e4c9b --- /dev/null +++ b/apps/server/src/research/inline-3.test.ts @@ -0,0 +1,139 @@ +import { expect, it } from "bun:test"; +import { setup } from "./test-support"; + +it("retries a failed inline placement without publishing or enqueuing early", async () => { + let context = await setup(); + let placed = false; + let input = { + channelId: context.channelId, + question: "Which public evidence supports version 3?", + originMessageId: "01K39QZG000000000000000004", + requestedBy: context.userId, + placeReference: async () => { + if (!placed) throw new Error("document commit failed"); + return "placed" as const; + }, + }; + await expect(context.service.startPlannerInline(input)).rejects.toThrow( + "document commit failed", + ); + expect(context.publications).toEqual([]); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + let [durable] = await context.storage.research.list(context.channelId, 100); + expect(durable?.origin).toBe("planner"); + placed = true; + await context.restart().recoverPendingPlannerInline(async (channelId, id) => { + expect(channelId).toBe(context.channelId); + expect(id).toBe(durable?.id); + return "placed"; + }); + expect( + (await context.storage.research.get(context.channelId, durable!.id))?.workspace + .inlineReference, + ).toBe("placed"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(1); + let retry = await context.restart().startPlannerInline(input); + expect(retry.repeated).toBe(true); + expect(retry.request.stage).toBe("queued"); + expect(retry.request.id).toBe(durable?.id); +}); + +it("recovers after the card commit but before the placement marker advances", async () => { + let context = await setup(); + let references = new Set(); + let placed = context.storage.research.markReferencePlaced; + let interrupted = true; + context.storage.research.markReferencePlaced = async input => { + if (interrupted) throw new Error("crash after document commit"); + return placed(input); + }; + let input = { + channelId: context.channelId, + question: "Which API contracts changed?", + originMessageId: "01K39QZG000000000000000007", + requestedBy: context.userId, + placeReference: async (id: string) => { + references.add(id); + return "placed" as const; + }, + }; + await expect(context.service.startPlannerInline(input)) + .rejects.toThrow("crash after document commit"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + interrupted = false; + await context.restart().recoverPendingPlannerInline(async (_channelId, id) => { + references.add(id); + return "placed"; + }); + expect(references.size).toBe(1); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(1); +}); + +it("defers an implementation-locked card and recovers other requests", async () => { + let context = await setup(); + let ids: string[] = []; + for (let suffix of ["a", "b"]) { + await expect(context.service.startPlannerInline({ + channelId: context.channelId, + question: `Research brief ${suffix}`, + originMessageId: `01K39QZG00000000000000000${suffix}`, + requestedBy: context.userId, + placeReference: async id => { + ids.push(id); + return "deferred"; + }, + })).rejects.toMatchObject({ code: "not-ready" }); + } + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + let locked = ids[0]!; + let result = await context.restart().recoverPendingPlannerInline(async (_channelId, id) => + id === locked ? "deferred" : "placed" + ); + expect(result).toEqual({ deferred: 1 }); + expect( + (await context.storage.research.get(context.channelId, locked))?.workspace + .inlineReference, + ).toBe("pending"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(1); + let resumed = await context.restart().recoverPendingPlannerInline(async () => "placed"); + expect(resumed).toEqual({ deferred: 0 }); + expect( + (await context.storage.research.get(context.channelId, locked))?.workspace + .inlineReference, + ).toBe("placed"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(2); +}); + +it("places pending cards when research execution is temporarily disabled", async () => { + let context = await setup({ answer: false }); + let saved = await context.storage.research.start({ + id: "disabled-research", + channelId: context.channelId, + title: "Disabled research", + question: "Which API contracts changed?", + origin: "planner", + originMessageId: "01K39QZG000000000000000008", + inlineReference: "pending", + createdBy: context.userId, + turnId: "disabled-turn", + messageId: "disabled-message", + requestId: "disabled-request", + idempotencyKey: "disabled-research", + fingerprint: "disabled-research", + now: context.advance(), + lease: context.lease, + }); + let placed: string[] = []; + await context.service.recoverPendingPlannerInline(async (_channelId, id) => { + placed.push(id); + return "placed"; + }, context.channelId); + expect(placed).toEqual([saved.workspace.id]); + expect( + (await context.storage.research.get(context.channelId, saved.workspace.id)) + ?.workspace.inlineReference, + ).toBe("placed"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + expect((await context.storage.research.listReferenceRecovery(100)).map(value => value.id)) + .toContain(saved.workspace.id); +}); diff --git a/apps/server/src/research/inline-4.test.ts b/apps/server/src/research/inline-4.test.ts new file mode 100644 index 00000000..73836958 --- /dev/null +++ b/apps/server/src/research/inline-4.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "bun:test"; +import { ResearchWorkspaceError } from "./service"; +import { + answerArtifact, + createChild, + evidenceArtifact, + reconciledRequest, + REPORT, + REPOSITORY_ID, + settle, + setup, +} from "./test-support"; +import { childFixture, failInitialEvidence } from "./inline.test-fixtures"; + +it("refuses inline Planner research for an absent or child channel", async () => { + let context = await setup(); + let child = await createChild(childFixture(context)); + let placements = 0; + for (let channelId of ["missing-channel", child.id]) { + await expect(context.service.startPlannerInline({ + channelId, + question: "Create a child report", + originMessageId: "01K39QZG000000000000000005", + requestedBy: context.userId, + placeReference: async () => { + placements++; + return "placed"; + }, + })).rejects.toBeInstanceOf(ResearchWorkspaceError); + expect(await context.storage.research.list(channelId, 100)).toEqual([]); + } + expect(placements).toBe(0); +}); + +it("keeps a Planner card linked through failure, retry, and child publication", async () => { + let context = await setup(); + let references = new Set(); + let input = { + channelId: context.channelId, + question: "Which API contracts changed?", + originMessageId: "01K39QZG000000000000000006", + requestedBy: context.userId, + placeReference: async (id: string) => { + references.add(id); + return "placed" as const; + }, + }; + let started = await context.service.startPlannerInline(input); + await failInitialEvidence(context); + expect(await context.service.request(context.channelId, started.request.id)) + .toMatchObject({ stage: "failed" }); + await context.service.retryRequest({ + channelId: context.channelId, + workspaceId: started.request.id, + }); + await settle(context, "research-evidence", evidenceArtifact); + await reconciledRequest(context.service, context.channelId, started.request.id); + await settle(context, "research-answer", answerArtifact); + let ready = await reconciledRequest(context.service, context.channelId, started.request.id); + expect(ready).toMatchObject({ stage: "ready", child: { title: REPORT.title } }); + expect(references).toEqual(new Set([started.request.id])); + expect((await context.storage.channels.list(REPOSITORY_ID, 100)).channels + .filter(channel => channel.parentChannelId === context.channelId)).toHaveLength(1); +}); diff --git a/apps/server/src/research/inline-memory.test.ts b/apps/server/src/research/inline-memory.test.ts new file mode 100644 index 00000000..a0ac0b74 --- /dev/null +++ b/apps/server/src/research/inline-memory.test.ts @@ -0,0 +1,90 @@ +import { expect, it, spyOn } from "bun:test"; + +import { setup } from "./test-support"; + +it("recovers one durably committed reference after its placement marker fails", async () => { + let context = await setup(); + let placements: Array<{ repeated: boolean }> = []; + let placeReference = async (workspaceId: string) => { + let saved = await context.storage.collaboration.load(context.channelId, context.advance()); + let committed = await context.storage.collaboration.commit({ + channelId: context.channelId, + lease: context.lease, + expectedRevision: saved!.channel.revision, + operationId: `research-reference:${workspaceId}`, + epoch: "research-test", + sidecar: { researchReference: workspaceId }, + events: [], + now: context.advance(), + }); + placements.push(committed); + return "placed" as const; + }; + let mark = spyOn(context.storage.research, "markReferencePlaced").mockRejectedValueOnce( + new Error("placement marker unavailable"), + ); + try { + await expect(context.service.startPlannerInline({ + channelId: context.channelId, + question: "Which APIs changed?", + originMessageId: "message-memory-recovery", + requestedBy: context.userId, + placeReference, + })).rejects.toThrow("placement marker unavailable"); + } finally { + mark.mockRestore(); + } + let [pending] = await context.storage.research.listReferenceRecovery(100); + expect(pending?.inlineReference).toBe("pending"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + expect(context.publications).toEqual([]); + expect((await context.storage.collaboration.load(context.channelId, context.advance()))?.sidecar) + .toEqual({ researchReference: pending!.id }); + let restarted = context.restart(); + await restarted.recoverPendingPlannerInline((_channelId, id) => placeReference(id)); + await restarted.recoverPendingPlannerInline((_channelId, id) => placeReference(id)); + expect(placements.map(value => value.repeated)).toEqual([false, true]); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toHaveLength(1); + expect((await context.storage.research.get(context.channelId, pending!.id))?.workspace) + .toMatchObject({ inlineReference: "placed" }); + expect( + (await context.storage.collaboration.load(context.channelId, context.advance())) + ?.channel.revision, + ).toBe(1); +}); + +it("does not enqueue when live authorization fails after reference placement", async () => { + let context = await setup(); + await expect(context.service.startPlannerInline({ + channelId: context.channelId, + question: "Which APIs changed?", + originMessageId: "message-owner-denied", + requestedBy: context.userId, + placeReference: async () => "placed", + beforeStart: () => { + throw new Error("active owner revoked"); + }, + })).rejects.toThrow("active owner revoked"); + let [pending] = await context.storage.research.listReferenceRecovery(100); + expect(pending?.inlineReference).toBe("placed"); + expect((await context.jobs.list(context.channelId, 100))?.jobs).toEqual([]); + expect(context.publications).toEqual([]); +}); + +it("rejects a new inline request after its parent is archived", async () => { + let context = await setup(); + await context.storage.channels.archive({ id: context.channelId, now: context.advance() }); + let placed = false; + await expect(context.service.startPlannerInline({ + channelId: context.channelId, + question: "Which APIs changed?", + originMessageId: "message-archived-parent", + requestedBy: context.userId, + placeReference: async () => { + placed = true; + return "placed"; + }, + })).rejects.toMatchObject({ code: "invalid-request" }); + expect(placed).toBe(false); + expect(await context.storage.research.list(context.channelId, 100)).toEqual([]); +}); diff --git a/apps/server/src/research/inline.test-fixtures.ts b/apps/server/src/research/inline.test-fixtures.ts new file mode 100644 index 00000000..049a2d10 --- /dev/null +++ b/apps/server/src/research/inline.test-fixtures.ts @@ -0,0 +1,77 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { ResearchWorkspaceService } from "./service"; +import { setup } from "./test-support"; + +export function acceptedOffer( + principalId: string, + brief = "Which public evidence supports version 3?", +): ConversationPlan.ResearchOffer { + return { + id: "offer-lookup", + needId: "evidence", + contextId: "version-3", + source: { + messageId: "message-lookup", + author: { kind: "member", handle: "octocat" }, + quote: brief, + start: 0, + end: brief.length, + }, + brief, + status: "accepted", + action: { + id: "action-lookup", + kind: "research", + actor: { kind: "member", handle: "octocat" }, + principalId, + at: 1, + }, + }; +} + +export async function failInitialEvidence(context: Awaited>) { + let [claimed] = await context.storage.jobs.claim({ + channelId: context.channelId, + claimOwner: "failing-worker", + count: 1, + ttlMs: 30_000, + now: context.advance(), + lease: context.lease, + }); + if (!claimed) throw new Error("initial evidence job was not claimable"); + await context.storage.jobs.fail({ + channelId: context.channelId, + jobId: claimed.id, + claimOwner: "failing-worker", + claimGeneration: claimed.claimGeneration, + reason: "internal-provider-detail", + now: context.advance(), + lease: context.lease, + }); + return claimed.id; +} + +export function terminalNotices(context: Awaited>) { + let notices: Array<{ channelId: string; id: string; text: string }> = []; + let service = new ResearchWorkspaceService({ + storage: context.storage, + jobs: context.jobs, + lease: () => context.lease, + current: async () => undefined, + publish: () => {}, + terminalNotice: async (channelId, id, text) => { + notices.push({ channelId, id, text }); + }, + }); + return { service, notices }; +} + +export function childFixture(context: Awaited>) { + return { + jobs: context.jobs, + lease: context.lease, + now: context.advance, + parent: context.channel, + storage: context.storage, + }; +} diff --git a/apps/server/src/research/placement-close.test-fixtures.ts b/apps/server/src/research/placement-close.test-fixtures.ts new file mode 100644 index 00000000..7440aabe --- /dev/null +++ b/apps/server/src/research/placement-close.test-fixtures.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { placeResearchReference } from "./placement"; +import { readFileSync } from "node:fs"; +import { parse } from "@babel/parser"; +import * as Plan from "../plan/service"; +import * as Chat from "../chat/service"; +import * as Rooms from "../rooms"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "../chat/job-authenticated-memory.test-fixtures"; +import { headingHarness } from "../chat/job-heading-harness.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { createConversationRuntime } from "../conversation-plan/runtime"; +import { researchStorage } from "../conversation-plan/accepted-research-storage.test-fixtures"; +// Isolate a failed lock cycle so it cannot strand Chat in the parent test process. +let timeout = setTimeout(() => process.exit(2), 5_000); +let opened = await openPlan(); +let plan = opened.plan; +let identity = await authenticatedMemory(opened); +let context = identity.context; +let runtime = createConversationRuntime({ + config: { agent: true }, + server: () => opened.server, + unavailable: () => false, +}); +let ws = { + data: { room: plan.id, client: "client", handle: "test", principalId: "U_test" }, + send() {}, +} as never; +let room = Rooms.join(ws); +room.plan = plan; +let main = readFileSync(new URL("../main.ts", import.meta.url), "utf8"); +let p = parse(main, { sourceType: "module", plugins: ["typescript"] }).program; +let names = ["withDocumentLock", "closeRoom"]; +let source = names.map(name => { + let n = p.body.find(n => n.type === "FunctionDeclaration" && n.id?.name === name)!; + return main.slice(n.start!, n.end!); +}).join("\n"); +let transpiled = new Bun.Transpiler({ loader: "ts" }).transformSync(source); +let { closeRoom, withDocumentLock } = new Function( + "documentLocks", + "Rooms", + "Service", + "conversationRuntime", + "documentBackend", + "server", + transpiled + "\nreturn {closeRoom,withDocumentLock};", +)(new Map(), Rooms, Plan, runtime, () => opened.backend, opened.server); +let { research, jobs } = researchStorage({ opened, plan } as Parameters[0]); +let entered = Promise.withResolvers(); +let release = Promise.withResolvers(); +let placementRequested = false; +let workspaceId: string | undefined; +let resultReturned = false; +context.createResearch = async request => { + let created = await research.startPlannerInline({ + channelId: plan.id, + question: request.question, + originMessageId: request.entryId, + requestedBy: request.userId, + requestedByHandle: request.handle, + placeReference: async id => { + workspaceId = id; + entered.resolve(); + await release.promise; + placementRequested = true; + return placeResearchReference(plan.id, id, { + get: Rooms.get, + exclusive: withDocumentLock, + detached: async (channelId, action) => { + let detached = await Plan.open(channelId, opened.backend, opened.server); + try { + return await action(detached); + } finally { + await Plan.close(detached); + } + }, + }); + }, + }); + return { + workspaceId: created.request.id, + state: created.request.state, + stage: created.request.stage, + }; +}; +let driver = headingHarness(async () => {}, async () => { + resultReturned = true; + driver.finish(); +}, { + name: "create_research_workspace", + input: { question: "Research the exact API compatibility." }, +}); +let agent = createPlannerAgent(driver.fake); +context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, + }) as never, + registerCredential: () => () => {}, + }); +await Chat.send(context, ws, { + kind: "chat:send", + ts: 0, + rid: "send", + requestId: crypto.randomUUID(), + to: "planner", + text: "Create research on exact API compatibility.", +}); +await entered.promise; +let closed = false; +let closeError: unknown; +let closing = closeRoom(room, true).then( + () => closed = true, + (error: unknown) => closeError = error, +); +await Bun.sleep(10); +console.log("beforeRelease", { + closed, + closing: !!room.closing, + chatClosed: plan.chat.closed, + chatRunning: !!plan.chat.running, +}); +release.resolve(); +await Promise.race([closing, Bun.sleep(300)]); +console.log("afterRelease", { + closed, + closeError: String(closeError ?? ""), + placementRequested, + resultReturned, + closing: !!room.closing, + chatClosed: plan.chat.closed, + chatRunning: !!plan.chat.running, +}); +if (!closed || closeError) { + console.log( + "REPRODUCED actual Chat/current Harness tool waits for documentLock held by closeRoom, which waits for Chat.running.", + ); + process.exit(1); +} +assert.equal(closeError, undefined); +assert.equal(closed, true); +assert.equal(room.closing, undefined); +assert.equal(plan.chat.running, undefined); +assert.equal(resultReturned, true); +assert.ok(workspaceId); +let stored = await opened.storage.research.get(plan.id, workspaceId); +assert.equal(stored?.workspace.id, workspaceId); +assert.equal(stored?.workspace.inlineReference, "pending"); +assert.equal(stored?.turns[0]?.evidenceJobId, undefined); +assert.deepEqual((await jobs.list(plan.id, 100))?.jobs, []); +console.log("Deferred request retains its identity, pending reference, and no queued work."); +Rooms.forget(room); +identity.revokeAll(); +clearTimeout(timeout); +console.log("No close deadlock in current Harness execution."); +process.exit(0); diff --git a/apps/server/src/research/placement-close.test.ts b/apps/server/src/research/placement-close.test.ts new file mode 100644 index 00000000..94bd3e29 --- /dev/null +++ b/apps/server/src/research/placement-close.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; + +test("actual research host tool defers placement while document close drains Chat", async () => { + let child = Bun.spawn([ + process.execPath, + new URL("./placement-close.test-fixtures.ts", import.meta.url).pathname, + ], { + stdout: "pipe", + stderr: "pipe", + }); + let [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); + expect(stderr).toContain("Research card placement is deferred"); + expect(stdout).toContain("No close deadlock in current Harness execution."); + expect(stdout).toContain( + "Deferred request retains its identity, pending reference, and no queued work.", + ); +}, 10_000); diff --git a/apps/server/src/research/placement.test.ts b/apps/server/src/research/placement.test.ts new file mode 100644 index 00000000..1ed4f068 --- /dev/null +++ b/apps/server/src/research/placement.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { openPlan } from "../testing/plan"; +import * as Plan from "../plan/service"; +import { type PlacementDependencies, placeResearchReference } from "./placement"; + +test("closing room defers before requesting its document lock", async () => { + let calls: string[] = []; + let deps: PlacementDependencies = { + get: () => ({ plan: undefined, closing: Promise.resolve() }), + exclusive: async (_id, action) => { + calls.push("lock"); + return action(); + }, + detached: async () => { + throw new Error("must not open detached plan"); + }, + }; + expect(await placeResearchReference("channel", "workspace", deps)).toBe("deferred"); + expect(calls).toEqual([]); +}); + +test("closing that begins while waiting for the document lock also defers", async () => { + let closing: Promise | undefined; + let deps: PlacementDependencies = { + get: () => ({ plan: undefined, closing }), + exclusive: async (_id, action) => { + closing = Promise.resolve(); + return action(); + }, + detached: async () => { + throw new Error("must not open detached plan"); + }, + }; + expect(await placeResearchReference("channel", "workspace", deps)).toBe("deferred"); +}); + +test("closing Plan persistence defers live and detached placement without writes", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let revision = plan.persistence.revision; + let live = true; + let deps: PlacementDependencies = { + get: () => live ? { plan, closing: undefined } : undefined, + exclusive: async (_id, action) => action(), + detached: async (_id, action) => action(plan), + }; + try { + plan.persistence.closing = true; + expect(await placeResearchReference(plan.id, "workspace", deps)).toBe("deferred"); + live = false; + expect(await placeResearchReference(plan.id, "workspace", deps)).toBe("deferred"); + expect(plan.persistence.revision).toBe(revision); + } finally { + plan.persistence.closing = false; + await Plan.close(plan); + } +}); diff --git a/apps/server/src/research/placement.ts b/apps/server/src/research/placement.ts new file mode 100644 index 00000000..45ccb567 --- /dev/null +++ b/apps/server/src/research/placement.ts @@ -0,0 +1,30 @@ +import * as Plan from "../plan/service"; +import type { Room } from "../rooms"; + +export type PlacementDependencies = { + get(channelId: string): Pick | undefined; + exclusive(channelId: string, action: () => Promise): Promise; + detached(channelId: string, action: (plan: Plan.Plan) => Promise): Promise; +}; + +export function placeResearchReference( + channelId: string, + workspaceId: string, + deps: PlacementDependencies, +): Promise<"placed" | "deferred"> { + // Closing waits for Chat while holding this lock; a host tool must not wait behind it. + if (deps.get(channelId)?.closing) return Promise.resolve("deferred"); + return deps.exclusive(channelId, async () => { + let room = deps.get(channelId); + if (room?.closing) return "deferred"; + let active = room?.plan; + if (active) { + if (active.persistence.closing) return "deferred"; + return Plan.placeResearchReference(active, workspaceId); + } + return deps.detached(channelId, plan => { + if (plan.persistence.closing) return Promise.resolve("deferred"); + return Plan.placeResearchReference(plan, workspaceId); + }); + }); +} diff --git a/apps/server/src/research/service.test.ts b/apps/server/src/research/service.test.ts index 86251d6f..fee67a3a 100644 --- a/apps/server/src/research/service.test.ts +++ b/apps/server/src/research/service.test.ts @@ -1,3 +1,4 @@ +import { childFixture, failInitialEvidence } from "./inline.test-fixtures"; import { describe, expect, it, spyOn } from "bun:test"; import { parse } from "@chopin/dialect"; @@ -30,38 +31,6 @@ import { import type { ResearchReport } from "../jobs/research-workspace"; import type { JsonValue } from "../storage/model"; -async function failInitialEvidence(context: Awaited>) { - let [claimed] = await context.storage.jobs.claim({ - channelId: context.channelId, - claimOwner: "failing-worker", - count: 1, - ttlMs: 30_000, - now: context.advance(), - lease: context.lease, - }); - if (!claimed) throw new Error("initial evidence job was not claimable"); - await context.storage.jobs.fail({ - channelId: context.channelId, - jobId: claimed.id, - claimOwner: "failing-worker", - claimGeneration: claimed.claimGeneration, - reason: "internal-provider-detail", - now: context.advance(), - lease: context.lease, - }); - return claimed.id; -} - -function childFixture(context: Awaited>) { - return { - jobs: context.jobs, - lease: context.lease, - now: context.advance, - parent: context.channel, - storage: context.storage, - }; -} - async function legacyChildEvidence(context: Awaited>) { let fixture = childFixture(context); let child = await createChild(fixture); diff --git a/apps/server/src/research/service.ts b/apps/server/src/research/service.ts index 6a8b78a9..b3734b95 100644 --- a/apps/server/src/research/service.ts +++ b/apps/server/src/research/service.ts @@ -1,4 +1,6 @@ -import { createHash } from "node:crypto"; +import { digest, fingerprint, startIdentity } from "./start-identity"; +import type { ValidatedStartResearchRequest } from "./start-identity"; +import { childDocumentPath } from "@chopin/protocol/document-url"; import { jobDetail as serializedJobDetail } from "../jobs/browser"; import { @@ -7,11 +9,12 @@ import { parseResearchEvidenceArtifact, } from "../jobs/research-workspace"; import { RESEARCH_REPOSITORY_WORKSPACE_LIMIT, researchAttemptDisposition } from "../storage/model"; +import { StorageError } from "../storage/errors"; import { MAX_TITLE_LENGTH } from "../channels/title"; import { publishInitialResearchChild } from "./publication"; import { projectRequestView } from "./request-view"; -import type { Job, Research } from "@chopin/protocol"; +import type { ConversationPlan, Job, Research } from "@chopin/protocol"; import type { ResearchAnswerInput, ResearchEvidence, @@ -60,6 +63,7 @@ export type ResearchWorkspaceServiceOptions = { workspaceId: string, revision: number, ) => void | Promise; + terminalNotice?: (channelId: string, id: string, text: string) => Promise; clock?: () => Date; id?: () => string; }; @@ -101,15 +105,9 @@ export type StartPlannerResearchRequest = { beforeStart?: () => void | Promise; }; -type ValidatedStartResearchRequest = { - channelId: string; - question: string; - scope: string; - origin: "inline" | "planner"; - originMessageId?: string; - requestedBy: string; - requestedByHandle?: string; - beforeStart?: () => void | Promise; +export type StartPlannerInlineResearchRequest = StartPlannerResearchRequest & { + /** Must durably place the canonical card before research work can be enqueued. */ + placeReference: (workspaceId: string) => Promise<"placed" | "deferred">; }; export type ConfirmResearchDraft = { @@ -186,14 +184,6 @@ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; const HANDLE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; const ACTIVE_JOB_STATES = new Set(["pending", "paused", "running"]); -function digest(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function fingerprint(kind: string, value: JsonValue): string { - return digest(`${kind}\0${JSON.stringify(value)}`); -} - function safeId(value: unknown, field: string, maximum = MAX_ID): string { if ( typeof value !== "string" || value.length < 1 || value.length > maximum @@ -364,6 +354,7 @@ export class ResearchWorkspaceService { #lease: () => Lease; #current: (channelId: string) => Promise; #publish: ResearchWorkspaceServiceOptions["publish"]; + #terminalNotice: ResearchWorkspaceServiceOptions["terminalNotice"]; #clock: () => Date; #id: () => string; #tails = new Map>(); @@ -374,6 +365,7 @@ export class ResearchWorkspaceService { this.#lease = options.lease; this.#current = options.current; this.#publish = options.publish; + this.#terminalNotice = options.terminalNotice; this.#clock = options.clock ?? (() => new Date()); this.#id = options.id ?? (() => crypto.randomUUID()); } @@ -417,41 +409,114 @@ export class ResearchWorkspaceService { }); } + async startPlannerInline( + input: StartPlannerInlineResearchRequest, + ): Promise { + let channelId = safeId(input.channelId, "Channel id"); + let question = researchBrief(input.question); + let originMessageId = safeId( + input.originMessageId, + "Origin message id", + MAX_ORIGIN_MESSAGE_ID, + ); + let requestedBy = opaqueId(input.requestedBy, "Requesting member id"); + let requestedByHandle = handle(input.requestedByHandle); + return this.#start({ + channelId, + question, + scope: originMessageId, + origin: "planner-inline", + originMessageId, + requestedBy, + ...(requestedByHandle ? { requestedByHandle } : {}), + ...(input.beforeStart ? { beforeStart: input.beforeStart } : {}), + placeReference: input.placeReference, + }); + } + + /** Read only: accepted consent may precede request creation or job linkage. */ + async acceptedOfferLink( + channelId: string, + offer: ConversationPlan.ResearchOffer, + ): Promise<{ status: "pending" | "unlinked" | "linked"; researchRequestId?: string }> { + let verifiedChannelId = safeId(channelId, "Channel id"); + if (offer.status !== "accepted" || offer.action?.kind !== "research") { + throw new ResearchWorkspaceError("invalid-request", "Research offer is not accepted."); + } + let question = researchBrief(offer.brief); + let originMessageId = safeId( + offer.source.messageId, + "Origin message id", + MAX_ORIGIN_MESSAGE_ID, + ); + let requestedBy = opaqueId(offer.action.principalId, "Requesting member id"); + let requestedByHandle = handle(offer.action.actor.handle); + if (!requestedByHandle) { + throw new ResearchWorkspaceError("invalid-state", "Research offer has no consent actor."); + } + let identity = startIdentity({ + channelId: verifiedChannelId, + question, + scope: originMessageId, + origin: "planner-inline", + originMessageId, + requestedBy, + requestedByHandle, + }); + let detail = await this.#storage.research.findByIdempotencyKey( + verifiedChannelId, + identity.idempotencyKey, + ); + if (!detail) return { status: "pending" }; + let { workspace, turns, messages } = detail; + let initial = turns.find(turn => turn.ordinal === 1); + let firstMessage = initial + && messages.find(message => message.turnId === initial.id && message.authorKind === "member"); + if ( + workspace.channelId !== verifiedChannelId + || workspace.idempotencyKey !== identity.idempotencyKey + || workspace.fingerprint !== identity.fingerprint + || workspace.origin !== "planner" || workspace.originMessageId !== originMessageId + || workspace.proposedQuestion !== question || workspace.confirmedQuery !== question + || workspace.createdBy !== requestedBy + || !workspace.inlineReference + || !initial || initial.kind !== "initial" || initial.workspaceId !== workspace.id + || initial.requestId !== originMessageId || initial.fingerprint !== identity.fingerprint + || initial.question !== question || initial.requestedBy !== requestedBy + || !firstMessage || firstMessage.userId !== requestedBy + || firstMessage.userHandle !== requestedByHandle || firstMessage.text !== question + ) { + throw new ResearchWorkspaceError("invalid-state", "Research offer request identity differs."); + } + return { + status: workspace.inlineReference === "placed" && initial.evidenceJobId + ? "linked" + : "unlinked", + researchRequestId: workspace.id, + }; + } + async #start(input: ValidatedStartResearchRequest): Promise { await this.#requireTopLevelChannel(input.channelId); this.#requireDefinition("research-evidence"); this.#requireDefinition("research-answer"); - let requestFingerprint = fingerprint( - `research-${input.origin}`, - input.origin === "inline" - ? { - channelId: input.channelId, - question: input.question, - requestedBy: input.requestedBy, - requestedByHandle: input.requestedByHandle ?? null, - } - : { - channelId: input.channelId, - question: input.question, - originMessageId: input.originMessageId!, - requestedBy: input.requestedBy, - requestedByHandle: input.requestedByHandle ?? null, - }, - ); + let identity = startIdentity(input); + let durableOrigin = input.origin === "planner-inline" ? "planner" : input.origin; let stored = await this.#storage.research.start({ id: this.#newId("Workspace id"), channelId: input.channelId, title: researchWorkspaceTitle(input.question), question: input.question, - origin: input.origin, + origin: durableOrigin, + ...(input.origin === "planner-inline" ? { inlineReference: "pending" as const } : {}), ...(input.originMessageId ? { originMessageId: input.originMessageId } : {}), createdBy: input.requestedBy, ...(input.requestedByHandle ? { createdByHandle: input.requestedByHandle } : {}), turnId: this.#newId("Turn id"), messageId: this.#newId("Message id"), requestId: input.scope, - idempotencyKey: `research-${input.origin}:${digest(input.scope).slice(0, 48)}`, - fingerprint: requestFingerprint, + idempotencyKey: identity.idempotencyKey, + fingerprint: identity.fingerprint, now: this.#time(), lease: this.#lease(), }); @@ -461,6 +526,28 @@ export class ResearchWorkspaceService { if (!initial) { throw new ResearchWorkspaceError("invalid-state", "Research request has no initial work."); } + if (current.workspace.inlineReference && !input.placeReference) { + throw new ResearchWorkspaceError( + "invalid-state", + "Inline Planner research requires a document placement callback.", + ); + } + if (input.placeReference) { + let placement = await input.placeReference(current.workspace.id); + if (placement === "deferred") { + throw new ResearchWorkspaceError( + "not-ready", + "Research card placement is deferred while implementation is active.", + ); + } + if (current.workspace.inlineReference === "pending") { + await this.#storage.research.markReferencePlaced({ + channelId: input.channelId, + workspaceId: current.workspace.id, + lease: this.#lease(), + }); + } + } if (initial.evidenceJobId === undefined) await input.beforeStart?.(); await this.#ensureEvidence(current.workspace, initial); }); @@ -469,6 +556,78 @@ export class ResearchWorkspaceService { return { request, repeated: stored.repeated }; } + /** Recover a committed Planner request even when its initiating turn will never replay. */ + async recoverPendingPlannerInline( + placeReference: ( + channelId: string, + workspaceId: string, + ) => Promise<"placed" | "deferred">, + channelId?: string, + ): Promise<{ deferred: number }> { + if (channelId !== undefined) safeId(channelId, "Channel id"); + let afterId: string | undefined; + let deferred = 0; + while (true) { + let page = await this.#storage.research.listReferenceRecovery(100, afterId, channelId); + for (let workspace of page) { + let outcome = await this.#exclusive(workspace.channelId, workspace.id, async () => { + let detail = await this.#stored(workspace.channelId, workspace.id); + if (!detail.workspace.inlineReference) return "skipped"; + let channel = await this.#storage.channels.get(workspace.channelId); + if (!channel || channel.archivedAt) return "skipped"; + let placement = await placeReference(workspace.channelId, workspace.id); + if (placement === "deferred") return "deferred"; + if (detail.workspace.inlineReference === "pending") { + await this.#storage.research.markReferencePlaced({ + channelId: workspace.channelId, + workspaceId: workspace.id, + lease: this.#lease(), + }); + } + let initial = detail.turns.find(value => value.kind === "initial"); + if (!initial) { + throw new ResearchWorkspaceError( + "invalid-state", + "Research request has no initial work.", + ); + } + if (this.#hasDefinition("research-evidence") && this.#hasDefinition("research-answer")) { + await this.#ensureEvidence(detail.workspace, initial); + } + return "placed"; + }); + if (outcome === "deferred") deferred++; + } + if (page.length < 100) return { deferred }; + afterId = page.at(-1)!.id; + } + } + + /** Revisit terminal Planner cards whose Chat notice may have been missed before shutdown. */ + async recoverTerminalPlannerInline(channelId?: string): Promise { + if (channelId !== undefined) safeId(channelId, "Channel id"); + let afterId: string | undefined; + while (true) { + let page = await this.#storage.research.listTerminalRecovery(100, afterId, channelId); + for (let candidate of page) { + try { + let channel = await this.#storage.channels.get(candidate.channelId); + if (!channel || channel.parentChannelId || channel.archivedAt) continue; + let job = await this.#jobs.get(candidate.channelId, candidate.jobId); + if (job) await this.jobChanged(job.job); + } catch (error) { + if ( + !(error instanceof ResearchWorkspaceError && error.code === "invalid-state") + && !(error instanceof StorageError && error.failure === "corrupt") + ) throw error; + console.warn(`[research] skipped invalid terminal request ${candidate.id}:`, error); + } + } + if (page.length < 100) return; + afterId = page.at(-1)!.id; + } + } + async createDraft(input: CreateResearchDraft): Promise { let channelId = safeId(input.channelId, "Channel id"); let createdBy = opaqueId(input.createdBy, "Creating member id"); @@ -647,7 +806,9 @@ export class ResearchWorkspaceService { if (!this.#validId(channelId) || !this.#validId(workspaceId)) return undefined; return this.#exclusive(channelId, workspaceId, async () => { let detail = await this.#storage.research.get(channelId, workspaceId); - return detail && this.#browserView(detail); + return detail?.workspace.origin === "planner" + ? undefined + : detail && this.#browserView(detail); }); } @@ -682,7 +843,7 @@ export class ResearchWorkspaceService { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { throw new ResearchWorkspaceError("invalid-request", "Research workspace limit is invalid."); } - return (await this.#storage.research.list(channelId, limit)).map(summary); + return (await this.#storage.research.list(channelId, limit, false)).map(summary); } async listRepository( @@ -701,6 +862,7 @@ export class ResearchWorkspaceService { repositoryId, limit, includeArchived, + false, ); return { channels: listed.channels.map(group => ({ @@ -837,7 +999,7 @@ export class ResearchWorkspaceService { async jobChanged(job: JobView): Promise { if ( - job.state !== "completed" + (job.state !== "completed" && job.state !== "failed") || (job.type !== "research-evidence" && job.type !== "research-answer") || !this.#validId(job.channelId) ) return; @@ -845,7 +1007,38 @@ export class ResearchWorkspaceService { let linked = await this.#storage.research.findTurnByJob(job.channelId, job.id); if (!linked) return; await this.#exclusive(job.channelId, linked.workspaceId, async () => { - await this.#reconciled(job.channelId, linked.workspaceId); + let detail = await this.#reconciled(job.channelId, linked.workspaceId); + if (!detail || linked.kind !== "initial" || detail.workspace.inlineReference !== "placed") { + return; + } + let request = await this.#requestView(detail); + if (request.stage !== "ready" && request.stage !== "failed") return; + let activeTurn = detail.turns.find(value => value.id === linked.id); + if ( + request.stage === "ready" + && (job.state !== "completed" || job.id !== activeTurn?.answerJobId) + || request.stage === "failed" + && (job.state !== "failed" + || job.id !== activeTurn?.answerJobId && job.id !== activeTurn?.evidenceJobId) + ) { + return; + } + let id = `research-${request.stage}:${detail.workspace.id}:${job.id}`; + let text = "Research could not be completed. You can retry it from the research card."; + if (request.stage === "ready") { + let parent = await this.#storage.channels.get(job.channelId); + if (!parent) { + throw new ResearchWorkspaceError("invalid-state", "Parent document is missing."); + } + let path = childDocumentPath( + parent.repositoryOwner, + parent.repositoryName, + parent.slug, + request.child.slug, + ); + text = `Research is ready. [Open the research document](${path}).`; + } + await this.#terminalNotice?.(job.channelId, id, text); }); } @@ -1366,10 +1559,11 @@ export class ResearchWorkspaceService { } async #requireTopLevelChannel(channelId: string): Promise { - if (!await this.#isTopLevelChannel(channelId)) { + let channel = await this.#storage.channels.get(channelId); + if (!channel || channel.parentChannelId || channel.archivedAt) { throw new ResearchWorkspaceError( "invalid-request", - "Child documents cannot start research.", + "Channel cannot start research.", ); } } diff --git a/apps/server/src/research/start-identity.ts b/apps/server/src/research/start-identity.ts new file mode 100644 index 00000000..7258fa22 --- /dev/null +++ b/apps/server/src/research/start-identity.ts @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; +import type { JsonValue } from "../storage/model"; + +export type ValidatedStartResearchRequest = { + channelId: string; + question: string; + scope: string; + origin: "inline" | "planner" | "planner-inline"; + originMessageId?: string; + requestedBy: string; + requestedByHandle?: string; + beforeStart?: () => void | Promise; + placeReference?: (workspaceId: string) => Promise<"placed" | "deferred">; +}; + +export function startIdentity(input: ValidatedStartResearchRequest): { + idempotencyKey: string; + fingerprint: string; +} { + let durableOrigin = input.origin === "planner-inline" ? "planner" : input.origin; + return { + idempotencyKey: `research-${durableOrigin}:${digest(input.scope).slice(0, 48)}`, + fingerprint: fingerprint( + `research-${durableOrigin}`, + input.origin === "inline" + ? { + channelId: input.channelId, + question: input.question, + requestedBy: input.requestedBy, + requestedByHandle: input.requestedByHandle ?? null, + } + : { + channelId: input.channelId, + question: input.question, + originMessageId: input.originMessageId!, + requestedBy: input.requestedBy, + requestedByHandle: input.requestedByHandle ?? null, + }, + ), + }; +} + +export function digest(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function fingerprint(kind: string, value: JsonValue): string { + return digest(`${kind}\0${JSON.stringify(value)}`); +} 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/storage/contract.ts b/apps/server/src/storage/contract.ts index 8bdafe2a..ccd5d2b6 100644 --- a/apps/server/src/storage/contract.ts +++ b/apps/server/src/storage/contract.ts @@ -1,3 +1,5 @@ +import { researchWorkspace } from "./research-inline-contract-fixtures"; +import { researchInlineContract } from "./research-inline-contract"; import { describe, expect, it } from "bun:test"; import { @@ -11,33 +13,12 @@ import { StorageError } from "./errors"; import { BACKGROUND_JOB_PROGRESS_LIMIT } from "./model"; import type { StorageFactory as Factory } from "./contract-support"; -import type { CreateResearchWorkspace, JsonValue, Lease } from "./model"; +import type { JsonValue } from "./model"; function attempt(action: () => Promise): Promise { return Promise.resolve().then(action); } -function researchWorkspace( - channelId: string, - createdBy: string, - lease: Lease, - overrides: Partial = {}, -): CreateResearchWorkspace { - return { - id: id("research-workspace"), - channelId, - title: "API compatibility research", - proposedQuestion: "Which API contracts changed?", - origin: "sidebar", - createdBy, - idempotencyKey: id("create-research"), - fingerprint: id("research-fingerprint"), - now: new Date("2026-01-07T03:04:05.000Z"), - lease, - ...overrides, - }; -} - /** The behavioral gate every built-in storage adapter must pass. */ export function storageContract(name: string, factory: Factory): void { describe(`${name} storage`, () => { @@ -1932,6 +1913,7 @@ export function storageContract(name: string, factory: Factory): void { }); researchPublicationContract(factory); + researchInlineContract(factory); it("rejects new archived research mutations but preserves exact replays", async () => { let storage = await opened(factory); diff --git a/apps/server/src/storage/memory/adapter.ts b/apps/server/src/storage/memory/adapter.ts index a541d255..a514c14f 100644 --- a/apps/server/src/storage/memory/adapter.ts +++ b/apps/server/src/storage/memory/adapter.ts @@ -3,6 +3,7 @@ import { documentSlug, documentSlugCandidate } from "../../channels/slug"; import { availableChannelTitle } from "../../channels/title"; import { MemoryBackgroundJobStore } from "./jobs"; import { MemoryResearchWorkspaceStore } from "./research"; +import { researchProjectionAllowed, ResearchProjectionConflict } from "../model"; import type { AddUserProject, @@ -742,14 +743,26 @@ export class MemoryStorage implements StorageAdapter { }); } - #commit(input: CommitChannel): Promise { + async #commit(input: CommitChannel): Promise { + this.#assertLease(input.lease); + let replay = this.#operations.get(input.channelId)?.get(input.operationId); + if (replay) return { ...replay, repeated: true }; + for (let change of input.researchProjections ?? []) { + let detail = await this.#research.get(input.channelId, change.id); + let initial = detail?.turns.find(turn => turn.kind === "initial"); + let jobId = initial?.answerJobId ?? initial?.evidenceJobId; + let job = jobId ? this.#jobs.detail(input.channelId, jobId)?.job : undefined; + if (!researchProjectionAllowed(input.channelId, change, detail?.workspace, initial, job)) { + throw new ResearchProjectionConflict(change.id); + } + } this.#assertLease(input.lease); let found = this.#channels.get(input.channelId); if (!found) throw missing(`channel ${input.channelId} does not exist`); let operations = this.#operations.get(input.channelId) ?? new Map(); this.#operations.set(input.channelId, operations); let repeated = operations.get(input.operationId); - if (repeated) return Promise.resolve({ ...repeated, repeated: true }); + if (repeated) return { ...repeated, repeated: true }; if (found.archivedAt && !input.allowArchived) { throw conflict(`channel ${input.channelId} is archived`); } diff --git a/apps/server/src/storage/memory/research.ts b/apps/server/src/storage/memory/research.ts index f27ad6e5..31a60520 100644 --- a/apps/server/src/storage/memory/research.ts +++ b/apps/server/src/storage/memory/research.ts @@ -27,6 +27,7 @@ import type { PublishInitialResearchReportResult, ResearchJobRole, ResearchMessage, + ResearchTerminalRecovery, ResearchTurn, ResearchWorkspace, ResearchWorkspaceDetail, @@ -189,6 +190,9 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { if (input.origin !== "inline" && input.origin !== "planner") { throw conflict("research workspace start origin is invalid"); } + if (input.inlineReference && input.origin !== "planner") { + throw conflict("inline reference requires Planner origin"); + } if ( input.origin === "inline" && input.originMessageId !== undefined || input.origin === "planner" && !input.originMessageId @@ -208,6 +212,9 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { `research workspace idempotency key ${input.idempotencyKey} was reused`, ); } + if (input.inlineReference && !repeated.inlineReference) { + repeated.inlineReference = "pending"; + } return { workspace: workspace(repeated), turn: turn(repeatedTurn), @@ -238,6 +245,7 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { confirmedQuery: input.question, origin: input.origin, originMessageId: input.originMessageId, + ...(input.inlineReference ? { inlineReference: input.inlineReference } : {}), createdBy: input.createdBy, confirmedBy: input.createdBy, revision: 0, @@ -280,6 +288,70 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { }; }); + readonly markReferencePlaced = (input: { + channelId: string; + workspaceId: string; + lease: Lease; + }): Promise => + this.#mutate(async () => { + this.#assertChannel(input.channelId, input.lease); + let found = this.#requireWorkspace(input.channelId, input.workspaceId); + if (!found.inlineReference) throw conflict("research reference placement is not required"); + found.inlineReference = "placed"; + }); + + readonly listReferenceRecovery = async ( + limit: number, + afterId?: string, + channelId?: string, + ): Promise => { + let count = Math.min(100, Math.max(1, limit)); + return [...this.#workspaces.values()] + .filter(value => !channelId || value.channelId === channelId) + .filter(value => + value.inlineReference === "pending" + || value.inlineReference === "placed" + && !(this.#turns.get(value.id) ?? [])[0]?.evidenceJobId + ) + .filter(value => !afterId || compareId(value.id, afterId) > 0) + .sort((left, right) => compareId(left.id, right.id)) + .slice(0, count) + .map(workspace); + }; + + readonly listTerminalRecovery = async ( + limit: number, + afterId?: string, + channelId?: string, + ): Promise => { + let count = Math.min(100, Math.max(1, limit)); + let candidates = [...this.#workspaces.values()] + .filter(value => value.origin === "planner" && value.inlineReference === "placed") + .filter(value => !channelId || value.channelId === channelId) + .filter(value => !afterId || compareId(value.id, afterId) > 0) + .sort((left, right) => compareId(left.id, right.id)); + let page: ResearchTerminalRecovery[] = []; + for (let value of candidates) { + let initial = (this.#turns.get(value.id) ?? []).find(turn => turn.kind === "initial"); + if (!initial) continue; + let answer = initial.answerJobId + ? await this.#options.job(value.channelId, initial.answerJobId) + : undefined; + let evidence = initial.evidenceJobId + ? await this.#options.job(value.channelId, initial.evidenceJobId) + : undefined; + let jobId: string | undefined; + if ( + answer?.job.state === "failed" + || answer?.job.state === "completed" && value.publishedChannelId + ) jobId = answer.job.id; + else if (evidence?.job.state === "failed") jobId = evidence.job.id; + if (jobId) page.push({ id: value.id, channelId: value.channelId, jobId }); + if (page.length === count) break; + } + return page; + }; + readonly confirm = ( input: ConfirmResearchWorkspace, ): Promise => @@ -682,10 +754,14 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { readonly list = async ( channelId: string, limit: number, + includePlanner = true, ): Promise => { let count = Math.min(100, Math.max(1, limit)); return [...this.#workspaces.values()] - .filter(value => value.channelId === channelId) + .filter(value => + value.channelId === channelId + && (includePlanner || value.origin !== "planner") + ) .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime() || compareId(left.id, right.id) ) @@ -697,6 +773,7 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { repositoryId: string, limit: number, includeArchived = false, + includePlanner = true, ): Promise => { let count = Math.min(RESEARCH_REPOSITORY_WORKSPACE_LIMIT, Math.max(1, limit)); let orderedChannels = this.#options.channels(repositoryId) @@ -706,6 +783,7 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { ); let workspacesByChannel = new Map(); for (let saved of this.#workspaces.values()) { + if (!includePlanner && saved.origin === "planner") continue; let values = workspacesByChannel.get(saved.channelId) ?? []; values.push(saved); workspacesByChannel.set(saved.channelId, values); @@ -757,6 +835,14 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { }; }; + readonly findByIdempotencyKey = async ( + channelId: string, + idempotencyKey: string, + ): Promise => { + let workspaceId = this.#idempotency.get(key(channelId, idempotencyKey)); + return workspaceId ? this.get(channelId, workspaceId) : undefined; + }; + readonly findTurnByJob = async ( channelId: string, jobId: string, diff --git a/apps/server/src/storage/model.ts b/apps/server/src/storage/model.ts index 8a1cdb10..869ac60b 100644 --- a/apps/server/src/storage/model.ts +++ b/apps/server/src/storage/model.ts @@ -1,3 +1,5 @@ +import { StorageError } from "./errors"; + /** Values storage adapters may persist without knowing their domain schema. */ export type JsonValue = | null @@ -236,10 +238,22 @@ export type CommitChannel = { sidecar?: JsonValue; events: EventInput[]; now: Date; + /** Browser Research reference changes that must remain authorized at commit time. */ + researchProjections?: ResearchProjectionChange[]; /** Lifecycle and shutdown maintenance may persist after document archival. */ allowArchived?: boolean; }; +export type ResearchProjectionChange = { id: string; action: "add" | "remove" }; + +/** A rejected browser projection is an invalid batch, not a storage outage. */ +export class ResearchProjectionConflict extends StorageError { + constructor(id: string) { + super("conflict", `research projection ${id} is no longer authorized`); + this.name = "ResearchProjectionConflict"; + } +} + export type CommitResult = { revision: number; sequence: number; @@ -484,6 +498,8 @@ export type ResearchWorkspace = { confirmedQuery: string | undefined; origin: ResearchWorkspaceOrigin; originMessageId: string | undefined; + /** Present only for Planner requests that must be placed in the parent document. */ + inlineReference?: "pending" | "placed"; createdBy: string; confirmedBy: string | undefined; revision: number; @@ -495,6 +511,12 @@ export type ResearchWorkspace = { export type ResearchWorkspaceSummary = ResearchWorkspace; +export type ResearchTerminalRecovery = { + id: string; + channelId: string; + jobId: string; +}; + export const RESEARCH_REPOSITORY_CHANNEL_LIMIT = 1_000; export const RESEARCH_REPOSITORY_WORKSPACE_LIMIT = 500; export const RESEARCH_REPOSITORY_CHANNEL_WORKSPACE_LIMIT = 100; @@ -531,6 +553,38 @@ export type ResearchTurn = { updatedAt: Date; }; +export function researchProjectionAllowed( + channelId: string, + change: ResearchProjectionChange, + workspace: + | Pick + | undefined, + initial: + | Pick< + ResearchTurn, + "id" | "workspaceId" | "kind" | "evidenceJobId" | "answerJobId" + > + | undefined, + job: Pick | undefined, +): boolean { + if (!workspace || workspace.id !== change.id || workspace.channelId !== channelId) return false; + if (!initial || initial.kind !== "initial" || initial.workspaceId !== change.id) return false; + if (change.action === "add" && workspace.origin !== "inline") return false; + let role = initial.answerJobId ? "answer" : "evidence"; + let jobId = initial.answerJobId ?? initial.evidenceJobId; + if ( + jobId && ( + !job || job.channelId !== channelId || job.type !== `research-${role}` + || job.targetKey !== `research-${role}:workspace:${change.id}:turn:${initial.id}:${role}` + ) + ) return false; + let terminal = job && ["failed", "cancelled", "superseded"].includes(job.state); + return change.action === "add" + ? !!job && !workspace.publishedChannelId + && ["pending", "paused", "running", "completed"].includes(job.state) + : !!workspace.publishedChannelId || !!terminal || !jobId; +} + export type ResearchMessageAuthorKind = "member" | "agent" | "system"; export type ResearchMessage = { @@ -578,6 +632,7 @@ export type StartResearchWorkspace = { question: string; origin: Extract; originMessageId?: string; + inlineReference?: "pending"; createdBy: string; createdByHandle?: string; turnId: string; diff --git a/apps/server/src/storage/port.ts b/apps/server/src/storage/port.ts index 43773fd8..84843697 100644 --- a/apps/server/src/storage/port.ts +++ b/apps/server/src/storage/port.ts @@ -46,7 +46,9 @@ import type { RenewBackgroundJob, ReplaceChannel, RequeueBackgroundJob, + ResearchTerminalRecovery, ResearchTurn, + ResearchWorkspace, ResearchWorkspaceDetail, ResearchWorkspaceRepositoryList, ResearchWorkspaceSummary, @@ -177,6 +179,21 @@ export interface BackgroundJobStore { export interface ResearchWorkspaceStore { create(input: CreateResearchWorkspace): Promise; start(input: StartResearchWorkspace): Promise; + markReferencePlaced(input: { + channelId: string; + workspaceId: string; + lease: Lease; + }): Promise; + listReferenceRecovery( + limit: number, + afterId?: string, + channelId?: string, + ): Promise; + listTerminalRecovery( + limit: number, + afterId?: string, + channelId?: string, + ): Promise; confirm(input: ConfirmResearchWorkspace): Promise; appendTurn(input: AppendResearchTurn): Promise; linkJob(input: LinkResearchTurnJob): Promise; @@ -189,13 +206,22 @@ export interface ResearchWorkspaceStore { publishInitialReport( input: PublishInitialResearchReport, ): Promise; - list(channelId: string, limit: number): Promise; + list( + channelId: string, + limit: number, + includePlanner?: boolean, + ): Promise; listRepository( repositoryId: string, limit: number, includeArchived?: boolean, + includePlanner?: boolean, ): Promise; get(channelId: string, workspaceId: string): Promise; + findByIdempotencyKey( + channelId: string, + idempotencyKey: string, + ): Promise; findTurnByJob(channelId: string, jobId: string): Promise; } diff --git a/apps/server/src/storage/postgres/adapter.test.ts b/apps/server/src/storage/postgres/adapter.test.ts index 2c502b5f..a2dcfdfb 100644 --- a/apps/server/src/storage/postgres/adapter.test.ts +++ b/apps/server/src/storage/postgres/adapter.test.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import { documentSlug } from "../../channels/slug"; import { storageContract } from "../contract"; import { StorageError } from "../errors"; +import { ResearchProjectionConflict } from "../model"; +import { contractId, userAndChannel } from "../contract-support"; import { PostgresStorage } from "./adapter"; import { migrate, verifyMigrations } from "./migrations"; import { backfillDocumentSlugs } from "./migrations/002_document_slugs"; @@ -366,6 +368,136 @@ if (url) { } }); + it("serializes a Research projection commit behind a concurrent job failure", async () => { + let storage = new PostgresStorage(url); + let sql = new SQL(url); + let locked = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker: Promise | undefined; + try { + await storage.migrate(); + let { channelId, userId, lease } = await userAndChannel(storage); + let jobLease = await storage.leases.acquire( + contractId("research-job-writer"), + contractId("research-worker"), + 60_000, + ); + if (!jobLease) throw new Error("test could not acquire a job storage lease"); + let now = new Date(); + let workspaceId = contractId("inline-research"); + let started = await storage.research.start({ + id: workspaceId, + channelId, + title: "Inline research", + question: "What changed?", + origin: "inline", + createdBy: userId, + turnId: contractId("initial-turn"), + messageId: contractId("initial-message"), + requestId: contractId("initial-request"), + idempotencyKey: contractId("research-start"), + fingerprint: contractId("research-fingerprint"), + now, + lease, + }); + let job = await storage.jobs.enqueue({ + id: contractId("research-answer"), + channelId, + type: "research-answer", + version: 1, + origin: "user", + targetKey: `research-answer:workspace:${workspaceId}:turn:${started.turn.id}:answer`, + idempotencyKey: contractId("research-enqueue"), + fingerprint: contractId("research-job-fingerprint"), + input: { question: "What changed?" }, + availableAt: now, + now, + lease: jobLease, + }); + await storage.research.linkJob({ + channelId, + workspaceId, + turnId: started.turn.id, + role: "answer", + jobId: job.job.id, + now, + lease: jobLease, + }); + let [claimed] = await storage.jobs.claim({ + channelId, + claimOwner: contractId("worker"), + count: 1, + ttlMs: 60_000, + now: new Date(now.getTime() + 1), + lease: jobLease, + }); + if (!claimed) throw new Error("Research answer job was not claimable"); + let initial = await storage.collaboration.load(channelId, now); + if (!initial) throw new Error("Research parent channel could not be loaded"); + + blocker = sql.begin(async transaction => { + await transaction`SELECT id FROM background_jobs WHERE id = ${job.job.id} FOR UPDATE`; + locked.resolve(); + await release.promise; + }); + await locked.promise; + + let failed = storage.jobs.fail({ + channelId, + jobId: job.job.id, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + reason: "test failure", + now: new Date(now.getTime() + 2), + lease: jobLease, + }); + let waitForLockWaiters = async (expected: number) => { + let deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + let [row] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count + FROM pg_stat_activity + WHERE datname = current_database() + AND wait_event_type = 'Lock' + AND query ILIKE '%FROM background_jobs%' + AND query ILIKE '%FOR UPDATE%' + `; + if (row?.count === expected) return; + await Bun.sleep(10); + } + throw new Error(`expected ${expected} Research row-lock waiters`); + }; + await waitForLockWaiters(1); + + let committing = storage.collaboration.commit({ + channelId, + lease, + expectedRevision: initial.channel.revision, + operationId: contractId("research-projection-race"), + epoch: "research-race-epoch", + update: new Uint8Array([1]), + sidecar: { revision: 1 }, + events: [], + now, + researchProjections: [{ id: workspaceId, action: "add" }], + }); + await waitForLockWaiters(2); + release.resolve(); + await blocker; + expect((await failed).state).toBe("failed"); + await expect(committing).rejects.toBeInstanceOf(ResearchProjectionConflict); + + let saved = await storage.collaboration.load(channelId, now); + expect(saved?.channel.revision).toBe(initial.channel.revision); + expect(saved?.updates).toHaveLength(0); + } finally { + release.resolve(); + await blocker?.catch(() => {}); + await storage.close(); + await sql.close(); + } + }); + it("backfills unique readable slugs for channels created before the slug migration", async () => { let sql = new SQL(url); let schema = `slug_migration_${crypto.randomUUID().replaceAll("-", "")}`; @@ -485,6 +617,7 @@ if (url) { "012_child_channels", "013_research_child_publication", "014_inline_research", + "015_planner_inline_reference", ]); expect(await sql<{ table: string | null }[]>`SELECT to_regclass('channel_slugs') AS table`) .toEqual([ diff --git a/apps/server/src/storage/postgres/adapter.ts b/apps/server/src/storage/postgres/adapter.ts index 74d45a38..caf53f9e 100644 --- a/apps/server/src/storage/postgres/adapter.ts +++ b/apps/server/src/storage/postgres/adapter.ts @@ -7,6 +7,7 @@ import { migrate, verifyMigrations } from "./migrations"; import { PostgresNavigationStore } from "./navigation"; import { PostgresBackgroundJobStore } from "./jobs"; import { PostgresResearchWorkspaceStore } from "./research"; +import { researchProjectionAllowed, ResearchProjectionConflict } from "../model"; import type { TransactionSQL } from "bun"; import type { @@ -1274,6 +1275,84 @@ export class PostgresStorage implements StorageAdapter { `channel ${input.channelId} is at revision ${current}, expected ${input.expectedRevision}`, ); } + for ( + let change of [...(input.researchProjections ?? [])].sort((a, b) => + a.id.localeCompare(b.id) + ) + ) { + // Publication locks channel, workspace, initial turn, then job in this order. + let [workspace] = await transaction< + Array<{ + id: string; + channelId: string; + origin: "inline" | "sidebar" | "planner"; + publishedChannelId: string | null; + }> + >` + SELECT id, channel_id AS "channelId", origin, + published_channel_id AS "publishedChannelId" + FROM research_workspaces + WHERE id = ${change.id} AND channel_id = ${input.channelId} + FOR UPDATE + `; + let [initial] = workspace + ? await transaction< + Array<{ + id: string; + workspaceId: string; + kind: "initial" | "follow-up" | "search-more"; + evidenceJobId: string | null; + answerJobId: string | null; + }> + >` + SELECT id, workspace_id AS "workspaceId", kind, + evidence_job_id AS "evidenceJobId", answer_job_id AS "answerJobId" + FROM research_turns + WHERE workspace_id = ${change.id} AND ordinal = 1 + FOR UPDATE + ` + : []; + let jobId = initial?.answerJobId ?? initial?.evidenceJobId; + let [job] = jobId + ? await transaction< + Array<{ + channelId: string; + type: string; + targetKey: string; + state: + | "pending" + | "paused" + | "running" + | "completed" + | "failed" + | "cancelled" + | "superseded"; + }> + >` + SELECT channel_id AS "channelId", type, target_key AS "targetKey", state + FROM background_jobs + WHERE id = ${jobId} AND channel_id = ${input.channelId} + FOR UPDATE + ` + : []; + if ( + !researchProjectionAllowed( + input.channelId, + change, + workspace + ? { ...workspace, publishedChannelId: workspace.publishedChannelId ?? undefined } + : undefined, + initial + ? { + ...initial, + evidenceJobId: initial.evidenceJobId ?? undefined, + answerJobId: initial.answerJobId ?? undefined, + } + : undefined, + job, + ) + ) throw new ResearchProjectionConflict(change.id); + } let sequence = integer(locked.nextSequence, "channel sequence"); let revision = current + 1; await transaction` diff --git a/apps/server/src/storage/postgres/conversation-restart.test.ts b/apps/server/src/storage/postgres/conversation-restart.test.ts new file mode 100644 index 00000000..a0fb6e86 --- /dev/null +++ b/apps/server/src/storage/postgres/conversation-restart.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { PostgresStorage } from "./adapter"; + +let database = process.env.TEST_DATABASE_URL; +let root = resolve(import.meta.dir, "../../../../.."); +let reconstruction = ` +import { PostgresStorage } from "./apps/server/src/storage/postgres/adapter"; +import * as Plan from "./apps/server/src/plan/service"; +import { applyEvent } from "./apps/server/src/conversation-plan/events"; +let storage = new PostgresStorage(process.env.TEST_DATABASE_URL); +let lease = await storage.leases.acquire("chopin:writer", crypto.randomUUID(), 30000); +if (!lease) throw new Error("writer lease unavailable"); +try { +let server = { publish() { return 0; } }; +let plan = await Plan.open(process.env.CHANNEL_ID, { + storage, lease: () => lease, fatal(error) { throw error; } +}, server); +if (process.env.RESTART_PHASE === "write") { + plan.conversationPlan = applyEvent(plan.conversationPlan, { + id: "opened", type: "thread.opened", threadId: "pilot", + observedThreadVersion: 0, origin: "planner", + actor: { kind: "agent" }, at: 1000, question: "Ship a pilot?" + }); + plan.conversationPlanJobs = [{ + id: "heading:document:m1", kind: "heading", target: "document", trigger: "m1", + status: "running", attempts: 0, at: "2026-10-02T10:00:00.000Z" + }]; + plan.conversationPlanEffects = ["job:heading:document"]; + await Plan.persist(plan); +} +console.log(JSON.stringify({ + source: Plan.source(plan), revision: plan.revision, seq: plan.document.seq, + state: plan.conversationPlan, jobs: plan.conversationPlanJobs, + receipts: plan.conversationPlanEffects, storageRevision: plan.persistence.revision +})); +} finally { + await storage.leases.release(lease); + await storage.close(); +} +// Exit without a room close/checkpoint; the next process must use committed storage. +process.exit(0); +`; + +if (database) { + describe("PostgreSQL conversation reconstruction", () => { + test( + "fresh processes retain accepted state and durably interrupt a running job once", + async () => { + let storage = new PostgresStorage(database); + await storage.migrate(); + let now = new Date(); + let userId = crypto.randomUUID(); + let channelId = crypto.randomUUID(); + try { + await storage.users.put({ id: userId, login: "ana", avatarUrl: "", now }); + let initial = await import("../../plan/service").then(module => + module.initial("# Pilot\n\nAlready accepted document output.") + ); + await storage.channels.create({ + id: channelId, + repositoryId: crypto.randomUUID(), + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Restart", + createdBy: userId, + now, + initial, + }); + let run = async (phase: string) => { + let child = Bun.spawn(["bun", "--eval", reconstruction], { + cwd: root, + env: { ...process.env, CHANNEL_ID: channelId, RESTART_PHASE: phase }, + stdout: "pipe", + stderr: "pipe", + }); + let deadline = setTimeout(() => child.kill(), 15_000); + let [output, errors, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]).finally(() => clearTimeout(deadline)); + expect(errors).toBe(""); + expect(code).toBe(0); + return JSON.parse(output); + }; + let written = await run("write"); + expect(written.jobs[0].status).toBe("running"); + let first = await run("read"); + expect(first.jobs).toMatchObject([{ + status: "failed", + reason: "interrupted", + attempts: 0, + }]); + expect(first.source).toBe(written.source); + expect(first.state).toEqual(written.state); + expect(first.receipts).toEqual(["job:heading:document"]); + expect([first.revision, first.seq]).toEqual([written.revision, written.seq]); + let stored = await storage.collaboration.load(channelId, new Date()); + expect(stored?.sidecar ?? stored?.snapshot?.sidecar).toMatchObject({ + conversationPlanJobs: [{ status: "failed", reason: "interrupted" }], + }); + let second = await run("read"); + expect(second).toEqual(first); + } finally { + await storage.close(); + } + }, + 30_000, + ); + }); +} else { + test.skip("PostgreSQL conversation reconstruction needs TEST_DATABASE_URL", () => {}); +} diff --git a/apps/server/src/storage/postgres/migrations.ts b/apps/server/src/storage/postgres/migrations.ts index a389b627..60a001c2 100644 --- a/apps/server/src/storage/postgres/migrations.ts +++ b/apps/server/src/storage/postgres/migrations.ts @@ -57,6 +57,9 @@ const MIGRATIONS = [{ }, { id: "014_inline_research", path: join(import.meta.dir, "migrations/014_inline_research.sql"), +}, { + id: "015_planner_inline_reference", + path: join(import.meta.dir, "migrations/015_planner_inline_reference.sql"), }] satisfies Migration[]; /** Navigation shipped as 002 before document slugs claimed that number on main. */ diff --git a/apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql b/apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql new file mode 100644 index 00000000..038b7346 --- /dev/null +++ b/apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql @@ -0,0 +1,7 @@ +ALTER TABLE research_workspaces + ADD COLUMN inline_reference text + CHECK (inline_reference IS NULL OR inline_reference IN ('pending', 'placed')); + +CREATE INDEX research_workspaces_inline_recovery + ON research_workspaces (id) + WHERE inline_reference IS NOT NULL; diff --git a/apps/server/src/storage/postgres/research.ts b/apps/server/src/storage/postgres/research.ts index c4fbbc37..cef46d9c 100644 --- a/apps/server/src/storage/postgres/research.ts +++ b/apps/server/src/storage/postgres/research.ts @@ -28,6 +28,7 @@ import type { PublishInitialResearchReportResult, ResearchMessage, ResearchMessageAuthorKind, + ResearchTerminalRecovery, ResearchTurn, ResearchTurnKind, ResearchWorkspace, @@ -62,6 +63,7 @@ type WorkspaceRow = { confirmedQuery: unknown; origin: unknown; originMessageId: unknown; + inlineReference: unknown; createdBy: unknown; confirmedBy: unknown; revision: unknown; @@ -172,6 +174,7 @@ const WORKSPACE_COLUMNS = ` confirmed_query AS "confirmedQuery", origin, origin_message_id AS "originMessageId", + inline_reference AS "inlineReference", created_by AS "createdBy", confirmed_by AS "confirmedBy", revision, @@ -309,6 +312,15 @@ function workspace(row: WorkspaceRow): ResearchWorkspace { throw corrupt(`research workspace ${id} has an invalid origin`); } let revision = integer(row.revision, "research workspace revision"); + let inlineReference = row.inlineReference === null ? undefined : row.inlineReference; + if ( + inlineReference !== undefined && inlineReference !== "pending" && inlineReference !== "placed" + ) { + throw corrupt(`research workspace ${id} has an invalid inline reference state`); + } + if (inlineReference && (row.origin !== "planner" || !row.originMessageId)) { + throw corrupt(`research workspace ${id} has an invalid inline reference origin`); + } let createdAt = date(row.createdAt, "research workspace creation time"); let updatedAt = date(row.updatedAt, "research workspace update time"); if (updatedAt < createdAt) throw corrupt(`research workspace ${id} has invalid timestamps`); @@ -324,6 +336,7 @@ function workspace(row: WorkspaceRow): ResearchWorkspace { confirmedQuery, origin: row.origin as ResearchWorkspaceOrigin, originMessageId: optionalText(row.originMessageId, "research workspace origin message id"), + ...(inlineReference ? { inlineReference } : {}), createdBy: text(row.createdBy, "research workspace creating member"), confirmedBy, revision, @@ -518,6 +531,9 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { if (input.origin !== "inline" && input.origin !== "planner") { throw conflict("research workspace start origin is invalid"); } + if (input.inlineReference && input.origin !== "planner") { + throw conflict("inline reference requires Planner origin"); + } let originMessageId = optionalInput( input.originMessageId, "workspace origin message id", @@ -552,6 +568,15 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { `research workspace idempotency key ${idempotencyKey} was reused`, ); } + if (input.inlineReference && !repeated.inlineReference) { + let [upgraded] = await transaction` + UPDATE research_workspaces SET inline_reference = 'pending' + WHERE id = ${repeated.id} + RETURNING ${transaction.unsafe(WORKSPACE_COLUMNS)} + `; + if (!upgraded) throw corrupt("research reference upgrade returned no record"); + repeated = workspace(upgraded); + } return { workspace: repeated, turn: repeatedTurn, @@ -574,11 +599,12 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { INSERT INTO research_workspaces ( id, channel_id, title, proposed_question, confirmed_query, origin, origin_message_id, + inline_reference, created_by, confirmed_by, revision, next_turn_ordinal, next_message_sequence, idempotency_key, fingerprint, created_at, updated_at ) VALUES ( ${workspaceId}, ${channelId}, ${title}, ${question}, ${question}, - ${input.origin}, ${originMessageId ?? null}, + ${input.origin}, ${originMessageId ?? null}, ${input.inlineReference ?? null}, ${createdBy}, ${createdBy}, 0, 2, 2, ${idempotencyKey}, ${fingerprint}, ${now}, ${now} ) @@ -615,6 +641,81 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { }; })); + readonly markReferencePlaced = (input: { + channelId: string; + workspaceId: string; + lease: Lease; + }): Promise => + this.#run("mark research reference placed", () => + this.#sql.begin(async transaction => { + await this.#fence(transaction, input.lease); + let [placed] = await transaction<{ id: string }[]>` + UPDATE research_workspaces SET inline_reference = 'placed' + WHERE channel_id = ${input.channelId} AND id = ${input.workspaceId} + AND inline_reference IS NOT NULL + RETURNING id + `; + if (!placed) throw conflict("research reference placement is not required"); + })); + + readonly listReferenceRecovery = ( + limit: number, + afterId?: string, + channelId?: string, + ): Promise => + this.#run("list research reference recovery", async () => { + let rows = await this.#sql` + SELECT ${this.#sql.unsafe(WORKSPACE_COLUMNS)} + FROM research_workspaces + WHERE (${afterId ?? ""} = '' OR id > ${afterId ?? ""}) + AND (${channelId ?? ""} = '' OR channel_id = ${channelId ?? ""}) + AND (inline_reference = 'pending' OR inline_reference = 'placed' + AND NOT EXISTS ( + SELECT 1 FROM research_turns + WHERE workspace_id = research_workspaces.id AND ordinal = 1 + AND evidence_job_id IS NOT NULL + )) + ORDER BY id ASC + LIMIT ${Math.min(100, Math.max(1, limit))} + `; + return rows.map(workspace); + }); + + readonly listTerminalRecovery = ( + limit: number, + afterId?: string, + channelId?: string, + ): Promise => + this.#run("list research terminal recovery", async () => { + let rows = await this.#sql<{ id: string; channelId: string; jobId: string }[]>` + SELECT research_workspaces.id, + research_workspaces.channel_id AS "channelId", + CASE + WHEN answer.state = 'failed' OR + (answer.state = 'completed' AND published_channel_id IS NOT NULL) + THEN answer.id + ELSE evidence.id + END AS "jobId" + FROM research_workspaces + JOIN research_turns initial ON initial.workspace_id = research_workspaces.id + AND initial.kind = 'initial' + LEFT JOIN background_jobs answer ON answer.id = initial.answer_job_id + AND answer.channel_id = research_workspaces.channel_id + LEFT JOIN background_jobs evidence ON evidence.id = initial.evidence_job_id + AND evidence.channel_id = research_workspaces.channel_id + WHERE research_workspaces.origin = 'planner' + AND research_workspaces.inline_reference = 'placed' + AND (${afterId ?? ""} = '' OR research_workspaces.id > ${afterId ?? ""}) + AND (${channelId ?? ""} = '' OR research_workspaces.channel_id = ${channelId ?? ""}) + AND (answer.state = 'failed' + OR answer.state = 'completed' AND published_channel_id IS NOT NULL + OR evidence.state = 'failed') + ORDER BY research_workspaces.id ASC + LIMIT ${Math.min(100, Math.max(1, limit))} + `; + return rows; + }); + readonly confirm = ( input: ConfirmResearchWorkspace, ): Promise => @@ -1115,6 +1216,7 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { readonly list = ( channelId: string, limit: number, + includePlanner = true, ): Promise => this.#run("list research workspaces", async () => { let count = Math.min(100, Math.max(1, limit)); @@ -1122,6 +1224,7 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { SELECT ${this.#sql.unsafe(WORKSPACE_COLUMNS)} FROM research_workspaces WHERE channel_id = ${channelId} + AND (${includePlanner} OR origin <> 'planner') ORDER BY updated_at DESC, id ASC LIMIT ${count} `; @@ -1132,6 +1235,7 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { repositoryId: string, limit: number, includeArchived = false, + includePlanner = true, ): Promise => this.#run( "list repository research workspaces", @@ -1174,6 +1278,7 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { SELECT ${transaction.unsafe(WORKSPACE_COLUMNS)} FROM research_workspaces WHERE research_workspaces.channel_id = repository_channels.id + AND (${includePlanner} OR research_workspaces.origin <> 'planner') ORDER BY research_workspaces.updated_at DESC, research_workspaces.id ASC LIMIT ${RESEARCH_REPOSITORY_CHANNEL_WORKSPACE_LIMIT} ) AS workspaces @@ -1250,6 +1355,18 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { }), ); + readonly findByIdempotencyKey = ( + channelId: string, + idempotencyKey: string, + ): Promise => + this.#run("find research workspace by request key", async () => { + let [row] = await this.#sql<{ id: unknown }[]>` + SELECT id FROM research_workspaces + WHERE channel_id = ${channelId} AND idempotency_key = ${idempotencyKey} + `; + return row ? this.get(channelId, text(row.id, "research workspace id")) : undefined; + }); + readonly findTurnByJob = (channelId: string, jobId: string): Promise => this.#run("find research turn by job", async () => { let rows = await this.#sql< diff --git a/apps/server/src/storage/postgres/startup.test.ts b/apps/server/src/storage/postgres/startup.test.ts index 6a9e991c..b0f935be 100644 --- a/apps/server/src/storage/postgres/startup.test.ts +++ b/apps/server/src/storage/postgres/startup.test.ts @@ -1,9 +1,14 @@ +import { SQL } from "bun"; import { afterEach, describe, expect, it } from "bun:test"; import { join } from "node:path"; +import * as Service from "../../plan/service"; +import { backgroundJob } from "../contract-support"; import { PostgresStorage } from "./adapter"; import type { Subprocess } from "bun"; +import type { SocketData } from "../../wire"; +import type { Server } from "bun"; let database = process.env.TEST_DATABASE_URL; let running: Subprocess[] = []; @@ -18,6 +23,7 @@ function spawn( port: number, stderr: "ignore" | "pipe" = "ignore", extra: Record = {}, + stdout: "ignore" | "pipe" = "ignore", ): Subprocess { let child = Bun.spawn(["bun", join(import.meta.dir, "../../main.ts")], { env: { @@ -36,13 +42,28 @@ function spawn( STORAGE_DRIVER: "postgres", ...extra, }, - stdout: "ignore", + stdout, stderr, }); running.push(child); return child; } +async function started(child: Subprocess): Promise { + let output = child.stdout as ReadableStream; + let reader = output.getReader(); + let content = ""; + try { + while (!content.includes("chopin ·")) { + let next = await reader.read(); + if (next.done) throw new Error("server exited before startup completed"); + content += new TextDecoder().decode(next.value); + } + } finally { + reader.releaseLock(); + } +} + async function ready(port: number): Promise { for (let attempt = 0; attempt < 200; attempt++) { try { @@ -61,7 +82,8 @@ if (database) { let setup = new PostgresStorage(database); await setup.migrate(); await setup.close(); - let first = spawn(9071); + let first = spawn(9071, "ignore", {}, "pipe"); + await started(first); await ready(9071); let session = await fetch("http://127.0.0.1:9071/api/session"); expect(await session.json()).toEqual({ user: null, agent: false }); @@ -108,7 +130,8 @@ if (database) { first.kill("SIGTERM"); expect(await first.exited).toBe(0); - let replacement = spawn(9072); + let replacement = spawn(9072, "ignore", {}, "pipe"); + await started(replacement); await ready(9072); expect(await storage.sessions.get(sessionId, now)).toBeUndefined(); expect((await storage.collaboration.load(channelId, now))!.agent).toMatchObject({ @@ -122,6 +145,289 @@ if (database) { expect(await replacement.exited).toBe(0); await storage.close(); }, 20_000); + + it("recovers an inline Research card without an open room, once across restarts", async () => { + let storage = new PostgresStorage(database); + await storage.migrate(); + let now = new Date(); + let userId = `user-${crypto.randomUUID()}`; + let channelId = crypto.randomUUID(); + let workspaceId = crypto.randomUUID(); + await storage.users.put({ id: userId, login: "mona", avatarUrl: "", now }); + await storage.channels.create({ + id: channelId, + repositoryId: `repository-${crypto.randomUUID()}`, + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Research recovery", + createdBy: userId, + now, + }); + let lease = await storage.leases.acquire("chopin:writer", crypto.randomUUID(), 30_000); + if (!lease) throw new Error("could not acquire setup lease"); + await storage.research.start({ + id: workspaceId, + channelId, + title: "Research request", + question: "What changed?", + origin: "planner", + originMessageId: crypto.randomUUID(), + inlineReference: "pending", + createdBy: userId, + turnId: crypto.randomUUID(), + messageId: crypto.randomUUID(), + requestId: crypto.randomUUID(), + idempotencyKey: `research-recovery-${workspaceId}`, + fingerprint: `research-recovery-${workspaceId}`, + now, + lease, + }); + await storage.leases.release(lease); + + let countCards = async () => { + let stored = await storage.collaboration.load(channelId, new Date()); + if (!stored) throw new Error("document is missing"); + let source = (await Service.readStored(stored)).source; + return source.split(``).length - 1; + }; + for (let port of [9073, 9074]) { + let child = spawn(port, "ignore", {}, "pipe"); + await started(child); + await ready(port); + expect((await storage.research.get(channelId, workspaceId))?.workspace.inlineReference) + .toBe("placed"); + expect(await countCards()).toBe(1); + child.kill("SIGTERM"); + expect(await child.exited).toBe(0); + } + await storage.close(); + }); + + it("recovers one missed terminal Chat notice across repeated server starts", async () => { + let storage = new PostgresStorage(database); + await storage.migrate(); + let now = new Date(); + let userId = `user-${crypto.randomUUID()}`; + let channelId = crypto.randomUUID(); + let workspaceId = crypto.randomUUID(); + await storage.users.put({ id: userId, login: "mona", avatarUrl: "", now }); + await storage.channels.create({ + id: channelId, + repositoryId: `repository-${crypto.randomUUID()}`, + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Terminal notice recovery", + createdBy: userId, + now, + }); + let lease = await storage.leases.acquire("chopin:writer", crypto.randomUUID(), 30_000); + if (!lease) throw new Error("could not acquire setup lease"); + let request = await storage.research.start({ + id: workspaceId, + channelId, + title: "Research request", + question: "What failed?", + origin: "planner", + originMessageId: crypto.randomUUID(), + inlineReference: "pending", + createdBy: userId, + turnId: crypto.randomUUID(), + messageId: crypto.randomUUID(), + requestId: crypto.randomUUID(), + idempotencyKey: `terminal-${workspaceId}`, + fingerprint: `terminal-${workspaceId}`, + now, + lease, + }); + await storage.research.markReferencePlaced({ channelId, workspaceId, lease }); + let queued = await storage.jobs.enqueue(backgroundJob(channelId, lease, { + type: "research-evidence", + targetKey: `research-evidence:workspace:${workspaceId}:turn:${request.turn.id}:evidence`, + availableAt: now, + now, + })); + await storage.research.linkJob({ + channelId, + workspaceId, + turnId: request.turn.id, + role: "evidence", + jobId: queued.job.id, + now, + lease, + }); + let [claimed] = await storage.jobs.claim({ + channelId, + claimOwner: "failing-worker", + count: 1, + ttlMs: 30_000, + now, + lease, + }); + await storage.jobs.fail({ + channelId, + jobId: claimed!.id, + claimOwner: "failing-worker", + claimGeneration: claimed!.claimGeneration, + reason: "failure", + now, + lease, + }); + await storage.leases.release(lease); + + let noticeId = `research-failed:${workspaceId}:${queued.job.id}`; + for (let port of [9076, 9077]) { + let child = spawn(port, "ignore", {}, "pipe"); + await started(child); + await ready(port); + child.kill("SIGTERM"); + expect(await child.exited).toBe(0); + let durable = await storage.collaboration.load(channelId, new Date()); + expect(JSON.stringify(durable?.sidecar).split(noticeId).length - 1).toBe(1); + } + await storage.close(); + }); + + it("starts while placement is locked and recovers one card and job after release", async () => { + let storage = new PostgresStorage(database); + await storage.migrate(); + let now = new Date(); + let userId = `user-${crypto.randomUUID()}`; + let channelId = crypto.randomUUID(); + let workspaceId = crypto.randomUUID(); + await storage.users.put({ id: userId, login: "mona", avatarUrl: "", now }); + await storage.channels.create({ + id: channelId, + repositoryId: `repository-${crypto.randomUUID()}`, + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Locked research recovery", + createdBy: userId, + now, + }); + let lease = await storage.leases.acquire("chopin:writer", crypto.randomUUID(), 30_000); + if (!lease) throw new Error("could not acquire setup lease"); + let backend: Service.Backend = { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }; + let server = { + publish() { + return 0; + }, + } as unknown as Server; + let plan = await Service.open(channelId, backend, server); + plan.graph = { + versions: [{ + number: 1, + revision: 1, + planRevision: plan.revision, + state: "locked", + definition: { + tasks: [{ + id: "task", + title: "Implement", + context: "Research is pending.", + goal: "Complete the work.", + acceptance: ["Implementation is complete.", "Verification is recorded."], + dependsOn: [], + }], + }, + }], + }; + plan.execution = { + id: crypto.randomUUID(), + user: "mona", + client: { name: "Codex", version: "1" }, + session: crypto.randomUUID(), + planRevision: plan.revision, + graphVersion: 1, + graphRevision: 1, + repository: "octo-org/score", + branch: "test/locked-research", + commit: "deadbeef", + startedAt: now.toISOString(), + }; + await Service.persist(plan); + await Service.close(plan); + await storage.research.start({ + id: workspaceId, + channelId, + title: "Research request", + question: "What changed?", + origin: "planner", + originMessageId: crypto.randomUUID(), + inlineReference: "pending", + createdBy: userId, + turnId: crypto.randomUUID(), + messageId: crypto.randomUUID(), + requestId: crypto.randomUUID(), + idempotencyKey: `research-recovery-${workspaceId}`, + fingerprint: `research-recovery-${workspaceId}`, + now, + lease, + }); + await storage.leases.release(lease); + + let countCards = async () => { + let stored = await storage.collaboration.load(channelId, new Date()); + if (!stored) throw new Error("document is missing"); + let source = (await Service.readStored(stored)).source; + return source.split(``).length - 1; + }; + let child = spawn(9075, "pipe", { AGENT: "on" }, "pipe"); + await started(child); + await ready(9075); + expect(await countCards()).toBe(0); + expect((await storage.research.get(channelId, workspaceId))?.workspace.inlineReference) + .toBe("pending"); + expect((await storage.research.get(channelId, workspaceId))?.turns[0]?.evidenceJobId) + .toBeUndefined(); + + // Simulate a durable lifecycle release while this server has no open room. + let sql = new SQL(database); + try { + await sql` + UPDATE channel_state + SET sidecar = to_jsonb(jsonb_set( + (sidecar #>> '{}')::jsonb - 'execution', + '{graph,versions,0,state}', + '"approved"'::jsonb + )::text) + WHERE channel_id = ${channelId} + `; + } finally { + await sql.close(); + } + let placed = false; + for (let attempt = 0; attempt < 150; attempt++) { + let detail = await storage.research.get(channelId, workspaceId); + if (detail?.workspace.inlineReference === "placed" && detail.turns[0]?.evidenceJobId) { + placed = true; + break; + } + await Bun.sleep(100); + } + expect(placed).toBe(true); + expect(await countCards()).toBe(1); + let researchJobs = async () => + (await storage.jobs.list(channelId, 100))?.jobs + .filter(job => job.type === "research-evidence") ?? []; + expect(await researchJobs()).toHaveLength(1); + child.kill("SIGTERM"); + expect(await child.exited).toBe(0); + + let replacement = spawn(9076, "pipe", { AGENT: "on" }, "pipe"); + await started(replacement); + await ready(9076); + expect(await countCards()).toBe(1); + expect(await researchJobs()).toHaveLength(1); + replacement.kill("SIGTERM"); + expect(await replacement.exited).toBe(0); + await storage.close(); + }, 30_000); }); } else { describe("postgres server lifecycle", () => { diff --git a/apps/server/src/storage/research-inline-contract-1.ts b/apps/server/src/storage/research-inline-contract-1.ts new file mode 100644 index 00000000..e8bb19ce --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-1.ts @@ -0,0 +1,124 @@ +import { expect, it } from "bun:test"; +import { + backgroundJob, + contractId as id, + openedStorage as opened, + userAndChannel, +} from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract1(factory: Factory): void { + for (let terminal of ["failed", "cancelled", "published"] as const) { + it(`conditions Research projection commits on ${terminal} request state`, async () => { + let storage = await opened(factory); + try { + let { channelId, userId, lease } = await userAndChannel(storage); + let now = new Date("2026-01-07T03:04:05.000Z"); + let workspaceId = id("inline-research"); + let started = await storage.research.start({ + id: workspaceId, + channelId, + title: "Inline research", + question: "What changed?", + origin: "inline", + createdBy: userId, + turnId: id("initial-turn"), + messageId: id("initial-message"), + requestId: id("initial-request"), + idempotencyKey: id("inline-start"), + fingerprint: id("inline-fingerprint"), + now, + lease, + }); + let job = await storage.jobs.enqueue(backgroundJob(channelId, lease, { + type: "research-answer", + targetKey: `research-answer:workspace:${workspaceId}:turn:${started.turn.id}:answer`, + availableAt: now, + now, + })); + await storage.research.linkJob({ + channelId, + workspaceId, + turnId: started.turn.id, + role: "answer", + jobId: job.job.id, + now, + lease, + }); + let revision = 0; + let commit = async (action: "add" | "remove") => { + let result = await storage.collaboration.commit({ + channelId, + lease, + expectedRevision: revision, + operationId: id(`research-${action}`), + epoch: "research-epoch", + update: new Uint8Array([revision + 1]), + sidecar: { revision: revision + 1 }, + events: [], + now, + researchProjections: [{ id: workspaceId, action }], + }); + revision = result.revision; + }; + await commit("add"); + await expect(commit("remove")).rejects.toMatchObject({ failure: "conflict" }); + let [claimed] = await storage.jobs.claim({ + channelId, + claimOwner: id("worker"), + count: 1, + ttlMs: 60_000, + now, + lease, + }); + if (!claimed) throw new Error("research job was not claimable"); + if (terminal === "failed") { + await storage.jobs.fail({ + channelId, + jobId: job.job.id, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + reason: "test failure", + now, + lease, + }); + } else if (terminal === "cancelled") { + await storage.jobs.cancel({ channelId, jobId: job.job.id, now, lease }); + } else { + await storage.jobs.settle({ + channelId, + jobId: job.job.id, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + artifact: { source: "# Report\n" }, + now, + lease, + }); + await commit("add"); + await storage.research.publishInitialReport({ + channelId, + workspaceId, + answerJobId: job.job.id, + title: "Report", + initial: { + generation: id("generation"), + epoch: "child-epoch", + source: "# Report\n", + sourceHash: "sha256:report", + document: new Uint8Array([1]), + sidecar: { revision: 0 }, + }, + now, + lease, + }); + } + await expect(commit("add")).rejects.toMatchObject({ failure: "conflict" }); + expect((await storage.collaboration.load(channelId, now))?.channel.revision) + .toBe(revision); + await commit("remove"); + } finally { + await storage.close(); + } + }); + } +} diff --git a/apps/server/src/storage/research-inline-contract-2.ts b/apps/server/src/storage/research-inline-contract-2.ts new file mode 100644 index 00000000..f6b03458 --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-2.ts @@ -0,0 +1,51 @@ +import { expect, it } from "bun:test"; +import { contractId as id, openedStorage as opened, userAndChannel } from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract2(factory: Factory): void { + it("allows removing a legacy unlinked Research reference", async () => { + let storage = await opened(factory); + try { + let { channelId, userId, lease } = await userAndChannel(storage); + let now = new Date("2026-01-07T03:04:05.000Z"); + let workspaceId = id("legacy-inline-research"); + await storage.research.start({ + id: workspaceId, + channelId, + title: "Legacy inline research", + question: "What changed?", + origin: "inline", + createdBy: userId, + turnId: id("legacy-initial-turn"), + messageId: id("legacy-initial-message"), + requestId: id("legacy-initial-request"), + idempotencyKey: id("legacy-inline-start"), + fingerprint: id("legacy-inline-fingerprint"), + now, + lease, + }); + let base = { + channelId, + lease, + expectedRevision: 0, + operationId: id("legacy-removal"), + epoch: "legacy-epoch", + update: new Uint8Array([1]), + events: [], + now, + }; + await expect(storage.collaboration.commit({ + ...base, + researchProjections: [{ id: workspaceId, action: "add" }], + })).rejects.toMatchObject({ failure: "conflict" }); + expect( + await storage.collaboration.commit({ + ...base, + researchProjections: [{ id: workspaceId, action: "remove" }], + }), + ).toMatchObject({ revision: 1 }); + } finally { + await storage.close(); + } + }); +} diff --git a/apps/server/src/storage/research-inline-contract-3.ts b/apps/server/src/storage/research-inline-contract-3.ts new file mode 100644 index 00000000..43c62fbe --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-3.ts @@ -0,0 +1,44 @@ +import { expect, it } from "bun:test"; +import { contractId as id, openedStorage as opened, userAndChannel } from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract3(factory: Factory): void { + it("retains Planner inline placement recovery through exact retries", async () => { + let storage = await opened(factory); + try { + let { userId, channelId, lease } = await userAndChannel(storage); + let input = { + id: id("planner-inline-workspace"), + channelId, + title: "Planner inline research", + question: "Which API contracts changed?", + origin: "planner" as const, + originMessageId: id("planner-inline-origin"), + inlineReference: "pending" as const, + createdBy: userId, + turnId: id("planner-inline-turn"), + messageId: id("planner-inline-message"), + requestId: id("planner-inline-request"), + idempotencyKey: id("planner-inline-start"), + fingerprint: "sha256:planner-inline-start", + now: new Date("2026-01-07T03:04:05.000Z"), + lease, + }; + let started = await storage.research.start(input); + expect(started.workspace.inlineReference).toBe("pending"); + expect((await storage.research.listReferenceRecovery(100)).map(value => value.id)) + .toContain(input.id); + await storage.research.markReferencePlaced({ channelId, workspaceId: input.id, lease }); + await storage.research.markReferencePlaced({ channelId, workspaceId: input.id, lease }); + expect((await storage.research.get(channelId, input.id))?.workspace.inlineReference) + .toBe("placed"); + expect((await storage.research.listReferenceRecovery(100)).map(value => value.id)) + .toContain(input.id); + let repeated = await storage.research.start(input); + expect(repeated.workspace.inlineReference).toBe("placed"); + expect(repeated.repeated).toBe(true); + } finally { + await storage.close(); + } + }); +} diff --git a/apps/server/src/storage/research-inline-contract-4.ts b/apps/server/src/storage/research-inline-contract-4.ts new file mode 100644 index 00000000..2d68b30f --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-4.ts @@ -0,0 +1,53 @@ +import { expect, it } from "bun:test"; +import { contractId as id, openedStorage as opened, userAndChannel } from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract4(factory: Factory): void { + it("finds a Planner request by its channel-scoped durable key", async () => { + let storage = await opened(factory); + try { + let { userId, channelId, repositoryId, lease } = await userAndChannel(storage); + let input = { + id: id("lookup-workspace"), + channelId, + title: "Lookup research", + question: "Which API contracts changed?", + origin: "planner" as const, + originMessageId: id("lookup-origin"), + inlineReference: "pending" as const, + createdBy: userId, + createdByHandle: "octocat", + turnId: id("lookup-turn"), + messageId: id("lookup-message"), + requestId: id("lookup-request"), + idempotencyKey: id("lookup-key"), + fingerprint: "sha256:lookup-fingerprint", + now: new Date("2026-01-07T03:04:05.000Z"), + lease, + }; + await storage.research.start(input); + let other = await storage.channels.create({ + id: id("other-channel"), + repositoryId, + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Other document", + createdBy: userId, + now: input.now, + }); + expect(await storage.research.findByIdempotencyKey(channelId, input.idempotencyKey)) + .toEqual(await storage.research.get(channelId, input.id)); + expect(await storage.research.findByIdempotencyKey(channelId, "missing")) + .toBeUndefined(); + expect(await storage.research.findByIdempotencyKey(other.id, input.idempotencyKey)) + .toBeUndefined(); + await storage.research.markReferencePlaced({ channelId, workspaceId: input.id, lease }); + expect( + (await storage.research.findByIdempotencyKey(channelId, input.idempotencyKey)) + ?.workspace.inlineReference, + ).toBe("placed"); + } finally { + await storage.close(); + } + }); +} diff --git a/apps/server/src/storage/research-inline-contract-5.ts b/apps/server/src/storage/research-inline-contract-5.ts new file mode 100644 index 00000000..00dbda7f --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-5.ts @@ -0,0 +1,87 @@ +import { expect, it } from "bun:test"; +import { + backgroundJob, + contractId as id, + openedStorage as opened, + userAndChannel, +} from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract5(factory: Factory): void { + it("pages only terminal Planner inline requests by workspace ID", async () => { + let storage = await opened(factory); + try { + let { userId, channelId, lease } = await userAndChannel(storage); + let expected: string[] = []; + for (let index = 0; index < 4; index++) { + let workspaceId = `terminal-${index}-${crypto.randomUUID()}`; + let started = await storage.research.start({ + id: workspaceId, + channelId, + title: `Research ${index}`, + question: `Question ${index}`, + origin: "planner", + originMessageId: id(`origin-${index}`), + ...(index < 3 ? { inlineReference: "pending" as const } : {}), + createdBy: userId, + turnId: id(`turn-${index}`), + messageId: id(`message-${index}`), + requestId: id(`request-${index}`), + idempotencyKey: id(`start-${index}`), + fingerprint: id(`fingerprint-${index}`), + now: new Date("2026-01-07T03:04:05.000Z"), + lease, + }); + if (index < 3) { + await storage.research.markReferencePlaced({ channelId, workspaceId, lease }); + } + let job = await storage.jobs.enqueue(backgroundJob(channelId, lease, { + type: "research-evidence", + targetKey: `research-evidence:workspace:${workspaceId}:turn:${started.turn.id}:evidence`, + availableAt: new Date("2026-01-07T03:04:05.000Z"), + now: new Date("2026-01-07T03:04:05.000Z"), + })); + await storage.research.linkJob({ + channelId, + workspaceId, + turnId: started.turn.id, + role: "evidence", + jobId: job.job.id, + now: new Date("2026-01-07T03:04:06.000Z"), + lease, + }); + let [claimed] = await storage.jobs.claim({ + channelId, + claimOwner: `worker-${index}`, + count: 1, + ttlMs: 60_000, + now: new Date("2026-01-07T03:04:07.000Z"), + lease, + }); + await storage.jobs.fail({ + channelId, + jobId: claimed!.id, + claimOwner: `worker-${index}`, + claimGeneration: claimed!.claimGeneration, + reason: "failed", + now: new Date("2026-01-07T03:04:08.000Z"), + lease, + }); + if (index < 3) expected.push(workspaceId); + } + let recovered: string[] = []; + let after: string | undefined; + for (let index = 0; index < 3; index++) { + let page = await storage.research.listTerminalRecovery(1, after, channelId); + expect(page).toHaveLength(1); + recovered.push(page[0]!.id); + after = page[0]!.id; + } + expect(recovered).toEqual(expected); + expect(await storage.research.listTerminalRecovery(1, after, channelId)) + .toEqual([]); + } finally { + await storage.close(); + } + }); +} diff --git a/apps/server/src/storage/research-inline-contract-6.ts b/apps/server/src/storage/research-inline-contract-6.ts new file mode 100644 index 00000000..9175df4c --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-6.ts @@ -0,0 +1,39 @@ +import { expect, it } from "bun:test"; +import { contractId as id, openedStorage as opened, userAndChannel } from "./contract-support"; +import type { StorageFactory as Factory } from "./contract-support"; +import { researchWorkspace } from "./research-inline-contract-fixtures"; + +export function researchInlineContract6(factory: Factory): void { + it("filters hidden Planner requests before workspace listing limits", async () => { + let storage = await opened(factory); + try { + let { userId, channelId, repositoryId, lease } = await userAndChannel(storage); + let sidebar = await storage.research.create(researchWorkspace(channelId, userId, lease)); + let later = new Date(sidebar.workspace.updatedAt.getTime() + 1000); + await storage.research.start({ + id: id("hidden-planner-workspace"), + channelId, + title: "Hidden Planner request", + question: "Research a release", + origin: "planner", + originMessageId: id("hidden-planner-origin"), + inlineReference: "pending", + createdBy: userId, + turnId: id("hidden-planner-turn"), + messageId: id("hidden-planner-message"), + requestId: id("hidden-planner-request"), + idempotencyKey: id("hidden-planner-start"), + fingerprint: "sha256:hidden-planner-start", + now: later, + lease, + }); + expect((await storage.research.list(channelId, 1, false)).map(value => value.id)) + .toEqual([sidebar.workspace.id]); + let repository = await storage.research.listRepository(repositoryId, 1, false, false); + expect(repository.channels.flatMap(group => group.workspaces.map(value => value.id))) + .toContain(sidebar.workspace.id); + } finally { + await storage.close(); + } + }); +} diff --git a/apps/server/src/storage/research-inline-contract-fixtures.ts b/apps/server/src/storage/research-inline-contract-fixtures.ts new file mode 100644 index 00000000..ce80ab55 --- /dev/null +++ b/apps/server/src/storage/research-inline-contract-fixtures.ts @@ -0,0 +1,23 @@ +import { contractId as id } from "./contract-support"; +import type { CreateResearchWorkspace, Lease } from "./model"; + +export function researchWorkspace( + channelId: string, + createdBy: string, + lease: Lease, + overrides: Partial = {}, +): CreateResearchWorkspace { + return { + id: id("research-workspace"), + channelId, + title: "API compatibility research", + proposedQuestion: "Which API contracts changed?", + origin: "sidebar", + createdBy, + idempotencyKey: id("create-research"), + fingerprint: id("research-fingerprint"), + now: new Date("2026-01-07T03:04:05.000Z"), + lease, + ...overrides, + }; +} diff --git a/apps/server/src/storage/research-inline-contract.ts b/apps/server/src/storage/research-inline-contract.ts new file mode 100644 index 00000000..bb95da05 --- /dev/null +++ b/apps/server/src/storage/research-inline-contract.ts @@ -0,0 +1,16 @@ +import { researchInlineContract1 } from "./research-inline-contract-1"; +import { researchInlineContract2 } from "./research-inline-contract-2"; +import { researchInlineContract3 } from "./research-inline-contract-3"; +import { researchInlineContract4 } from "./research-inline-contract-4"; +import { researchInlineContract5 } from "./research-inline-contract-5"; +import { researchInlineContract6 } from "./research-inline-contract-6"; +import type { StorageFactory as Factory } from "./contract-support"; + +export function researchInlineContract(factory: Factory): void { + researchInlineContract1(factory); + researchInlineContract2(factory); + researchInlineContract3(factory); + researchInlineContract4(factory); + researchInlineContract5(factory); + researchInlineContract6(factory); +} 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" && ( + + )} +