From 96eaa70f761ffdf519bee7ecac36b73668d87916 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:47:10 +0100 Subject: [PATCH 001/152] Extract dormant Jev transport and map product migration --- apps/server/src/conversation-plan/jev.test.ts | 248 +++++++++++++++ apps/server/src/conversation-plan/jev.ts | 252 +++++++++++++++ docs/jev-product-extraction.md | 163 ++++++++++ docs/jev-product-source-map.tsv | 288 ++++++++++++++++++ 4 files changed, 951 insertions(+) create mode 100644 apps/server/src/conversation-plan/jev.test.ts create mode 100644 apps/server/src/conversation-plan/jev.ts create mode 100644 docs/jev-product-extraction.md create mode 100644 docs/jev-product-source-map.tsv 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md new file mode 100644 index 00000000..3a16ffeb --- /dev/null +++ b/docs/jev-product-extraction.md @@ -0,0 +1,163 @@ +# Jev product extraction — first milestone + +This is a finite extraction from a preserved prototype, not a verified release or +classifier-quality fix. Only the transport foundation is implemented in this milestone. + +- Product branch: `maggie/jev-chat-product`. +- Product worktree: `/Users/maggieappleton/.codex/worktrees/jev-chat-product/chopin`. +- Current-main base: `9a5ac1d0947a0422d3607c876296fe849362188d` (Bun 1.4.2). +- Preserved source: `maggie/archive-jev-prototype-20260930`, + `446a9779a937fa5be7cd3eb52fd7f3023d691ed2`, including unverified WIP. +- Shared ancestor for identifying prototype-authored changes: + `f9cd64b1eb271c4534e59667d6028f95825153bb`. +- Historical checkout `/Users/maggieappleton/.codex/worktrees/42e5/chopin` is read-only. +- Eval branch `maggie/chopin-eval-hub` is separately owned; no stack rebase is done here. + +## Exact path ownership + +[Source inventory](jev-product-source-map.tsv) assigns all 287 changed paths under +`apps`, `packages`, `e2e`, `scripts`, and the relevant configuration/deployment files, +relative to the shared ancestor. Each row identifies owner, feature, and disposition. +A path assignment is a selective-port candidate, not permission to copy its whole file. +Comparing main directly with the archive also shows later main-only work as deletions; +those apparent deletions are not prototype changes to extract. + +Every product test stays with its corresponding product slice. That includes the +D01/D02/D19-named regression tests: their names reflect discovery through evaluations, +but they exercise source provenance, deferral, scoped support and product behavior. +The large `pipeline.test.ts` remains product coverage; its size does not justify +silently dropping scenarios. No reserved dataset contents were opened for this map. + +| Feature | Product source paths | Dependencies and regression ownership | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Jev HTTP transport | `apps/server/src/conversation-plan/jev.ts`, `jev.test.ts` | Standalone; injected fetch tests. Implemented now. | +| Jev configuration | `apps/server/src/config.ts`, `config.test.ts`, `.env.example`, Compose files, `docs/self-hosting.md` | Selectively add `CONVERSATION_PLAN`, `JEV_API_KEY`, `JEV_MODEL`, `JEV_TIMEOUT_MS`; do not activate processing before durable integration. Transport also retains `TYPESAFE_API_KEY` fallback. | +| Wire and state authority | `packages/protocol/conversation-plan.d.ts`, `chat.d.ts`, `plan.d.ts`, `question.d.ts`, `index.d.ts` | Event/source/card/job/analysis/research/Save contracts; update server and browser consumers with producers. | +| Interpretation and policy | `apps/server/src/conversation-plan/{interpret,questions,quotes,quote-budget,policy,validation,sources}.ts` | Transport + wire + bounded source spans. Retain corresponding tests and `pipeline.test.ts`; exact policy extraction, no threshold changes. | +| Structured decision domain | `apps/server/src/conversation-plan/{domain,events,preference}.ts` | Accepted-event replay, corrections, attribution, scoped proposals and withdrawals. Keep domain/corrections/review/qualified-choice/provenance/deferral/support tests in inventory. | +| Processing and durable effects | `apps/server/src/conversation-plan/{service,service-opening,effects,cards}.ts` | Serialized queue, retry receipts, source freshness, outbox and card projection; service/effects/cards/opening tests and room integration required. | +| Decision definitions and drafts | `packages/question/src/{schema,limits,options,draft,answer,index}.ts`, `react/*` | Stable IDs, option growth and answer option IDs; retain question/options/React tests. No wholesale draft-mode replacement. | +| Decision records and lifecycle | `apps/server/src/questions/{records,store,service,card-actions,backfill,option-match,prose,write-prose,anchors}.ts` | Card metadata, reopen/discard, sources and prose projection. Retain all questions regression files in inventory, including top-level `questions.*.test.ts`. | +| Room/sidecar persistence | `apps/server/src/plan/{service,room,projections,edit,questionnaires}.ts` | Conversation state, retries, jobs, effects/receipts and pending card actions live in the versioned sidecar. Preserve current metadata, MCP and implementation guards; persistence/room/planner-job tests protect restore and rollback. | +| SQL/provider persistence | `apps/server/src/storage/{model,port,contract}.ts`, memory/postgres adapters and `research.ts`, migrations registry, `015_planner_inline_reference.sql` | Research inline-placement recovery field, adapter contract, startup and migration tests. New migration must follow target registry; never replace applied checksums. No database migration is run here. | +| Background Planner work | `apps/server/src/conversation-plan/{jobs,planner-jobs,job-context,job-prompts,prose-job}.ts`; `apps/server/src/agent/{heading-tool,refine-tool,revise-open-decision,decision-prose,job-scope,tools,planner}.ts` | Heading/refine/suggest/prose jobs, scoped tools, ownership and job freshness. Merge into current Harness agents and stream consumption; keep all matching tests. | +| Chat and socket integration | `apps/server/src/chat/{service,address,references}.ts`, queue/job/notice tests; `socket/authorization.ts` and tests; `main.ts` | Message enqueue, notices, Save/research/job routes, authorization and reconnect snapshots. Retain current denial rendering and auth revalidation. | +| Chat-first workspace | `apps/web/src/{room-workspace,workspace,resizable-pane,workspace-model}.tsx/ts`, theme, design-audit icons, tokens tests | Retain current navigation, local sign-in, design system and keyboard behavior while porting chat-first layout. Browser layout/responsive tests are product. | +| Save suggestions | `apps/server/src/conversation-plan/save-command.ts`; scoped proposal policy/domain/events; card actions/records; `apps/web/src/chat/{decision-entry,scoped-choice-entry,transcript}.tsx` | Proposed support differs from human-confirmed Save; scoped-save, pending-withdrawal, linked-card-choice and transcript/entry tests must follow these paths. No automatic acceptance. | +| Decision cards/editor | `packages/dialect/src/{dialect,limits,validate,index}.ts`, questionnaire node; `packages/editor/src/{card-meta,decision-state,decision-surface,decisions,questionnaires,provider,plan-editor,widget-options,widgets-plugin,index}.ts/tsx`, styles and `widgets/{questionnaire,card-gap,decision-deletion,discarded-navigation*,index}`; icon additions | Dialect allowlist + records + stable draft definition. Keep round-trip/provider/card/lifecycle/render tests; real browser save/discard/reopen/selection tests. | +| Document annotations/evidence | `packages/editor/src/{decision-format,decision-layer,decision-pin,decision-placement,evidence-geometry,comment-geometry,marks}.ts/tsx`, `widgets/evidence-hover.tsx`; `apps/web/src/conversation-plan/{source,evidence,evidence-popover,markers,links,store,card-parts}` and CSS | Source spans and authoritative records first; then geometry, highlighting, pin/scroll, orphan recovery. Keep geometry/pin/marks/source tests and browser anchor/evidence/prose stress scenarios. | +| Analysis popover | `apps/web/src/conversation-plan/analysis-overview.tsx`, tests and related store/evidence helpers | Wire snapshots + analysis history/corrections/retry; preserve original display without redesign. | +| Automatic research offers | Research types/domain/validation/policy/interpreter/service; `apps/server/src/research/service.ts`, research storage; `apps/web/src/chat/research-offer.tsx`, tests; Chat references | Keep implemented cost/context detection, frozen task options, human consent/dismissal, stable request identity, recovery and linking. Retain research-offer-detection/task-snapshot/consent tests; do not expand supported cases. | +| Product browser regression harness | `e2e/jev*.ts`, `planner-jobs.ts`, `research-offer-fixtures.ts`, server/database/github fixtures, Playwright config and `scripts/e2e.ts` | Mock only provider boundaries and exercise real domain/wire/scoped tools. Adapt to main's current fake Harness and local-auth server; do not overwrite supervisor/port setup. | +| Scripted Planner regression harness | `apps/server/src/conversation-plan/scripted-runner.ts` and tests | Explicitly test-only. Real scoped tools with local scripted calls; keep product tests, adapt old Copilot `Tool` typing to current Harness, isolate harness activation from normal startup. | + +Exact filenames, including every related test and modified E2E scenario, are in the +inventory. Shared package exports, icons, app dependency metadata, existing MCP/task +fixtures and responsive tests need selective integration; they are not a separate +license to port unrelated old code. + +## Material excluded from product extraction + +- `evals/**`, including conversation-shape and conversation-corpus datasets, + scoring, results, generated grades, screenshots and sealed examples: eval/artifact + owner; originals remain in the preserved snapshot and corpus evidence archive. +- `scripts/evaluate-jev*`, `evaluate-card-quality*`, `export-card-quality-snapshot*`, + their evaluator tests, `check-jev.ts` and `conversation-plan-demo.ts`: eval/dev + tooling owner. Scoring tests do not replace product regression tests. +- `docs/prototypes/conversation-plan/**`: historical specs, decisions, implementation + plans, growing issue diary, reports, mockups, evaluation notes, generated captures. + Preserve as historical reference; write concise current feature docs when each + capability actually ships instead of importing the diary. +- One-use eval listener/setup, if needed by eval runs, belongs only to eval tooling. + Never port it from old main/startup wiring into product runtime. +- Snapshot-era Bun pin, package/lock metadata, agent client/runtime/permissions and + Copilot-only job/worker plumbing: retain main's current Bun/Harness architecture. +- Main-only auth/device/local-store, Harness adapters/contract/session, design checks, + design components and existing test suites: retain; direct archive diff deletions + must not be applied. Unrelated editor/research/code styling changes are deferred. + +## Minimal dependency sequence + +1. **Transport (this milestone):** copy the standalone transport and five existing + timeout/abort/body tests; add offline serialization/result/bounds tests. No imports + into startup, flags, listeners, storage, UI or policy. +2. **Stored decision identity:** extract `identified()` and its stored-text guard + from `packages/question/src/schema.ts`, export it in `index.ts`, and selectively + port its source tests from `question.test.ts`. Preserve durable IDs/order, validate + duplicate IDs and raw string lengths. This can be tested within the question + package without adding cards, changing draft mode or requiring a database. +3. **Pure conversation state:** wire declarations + source validation + event replay, + domain and their tests. Keep research/Save types that are referenced; do not + split a type graph by silently deleting accepted-event cases. +4. **Stable card definitions and records:** question option growth and answer IDs, + record lifecycle, card metadata and dialect normalization. Port dependencies and + tests together; no live processing yet. +5. **Durable processing:** sidecar/room serialization, queue/retry/outbox/card effects, + exact interpretation policy, configuration and authorized socket handlers. Only + enable processing after durable commits, reconnect and disabled-mode tests pass. +6. **Planner jobs:** adapt heading/refine/suggest/prose tools and transcript notices + to current Harness, owner credentials and stream events. Preserve test-only + scripted scenarios through current fake Harness conventions. +7. **Product surfaces:** cards and Save suggestions, then chat-first layout, annotations, + evidence and analysis popover; port matching browser tests with each bounded slice. +8. **Research offers:** preserve consent/recovery/task snapshots/inline placement; + adapt current isolated research workers and child publication, then offer UI/tests. + +Steps 3–8 are dependency boundaries, not permission to implement them in one large +commit. Inspect coupling at each step and split only where intermediate code compiles +and its behavior is independently testable. Product regression coverage remains owned +by these steps even when eval tooling also consumes the modules. + +## Current-main compatibility blockers + +- Old Planner job code speaks direct Copilot SDK tools/sessions. Main speaks + `HarnessAgent` and AI SDK stream parts, supports Copilot and Pi, and rechecks the + live tool allowlist. Port tool behavior, not old clients or runtime files. +- Main's local device sign-in restores a new process session; it does not reclaim + Planner ownership. Processing/job/research startup cannot assume a restored + credential automatically authorizes replay or new work. +- Main description/research definitions use current isolated workers, job publication + and child reconciliation. Old research and runner changes must preserve those + boundaries, including active-owner fencing and persistence before publication. +- Proto sidecar additions interact with current description/creation/lifecycle fields; + whole-file replacement would lose newer durable fields and guards. +- Current design contracts and browser fixtures are newer than the archive. Preserve + them and selectively reconcile layout/card styles and test preloads. +- Existing Save quality failure (CP-063/D19): a linked direct choice is rejected by a + separate message-confidence gate. It remains preserved and unaddressed; extraction + must not tune policy or relabel the dataset to hide it. + +## First-slice verification and handoff + +Separate implementer and independent reviewer completed this bounded slice. The +reviewer approved it with no extraction regressions or major map omissions. + +- Jev production source is byte-identical to the preserved snapshot. +- All five original tests remain; five mocked boundary tests were added. +- Independent Bun 1.4.2 run: 10 passed, 0 failed, 43 assertions, including the + original response-after-20-seconds regression. +- `bun run types` passed for all workspace packages and E2E declarations. +- `bun run fix` and `bun run ci` passed. Two existing lint warnings remain in + `packages/editor/src/widgets/research.tsx` and + `apps/server/src/harness/github-tools.test.ts`; the design check reports its + one existing baseline finding and no new findings. Formatting touched only + this milestone's files; package metadata and lockfile remain unchanged. +- Inventory checked against Git: 287 paths, each once (273 product, 14 eval + tooling). The relative document link resolves with exact casing. + +Inherited transport caveats remain unchanged: extra runtime request properties can +override the selected model; pre-aborted callers still invoke fetch; optional noul +criteria do not have complete request bounds. Assess these in a separately scoped +change before live integration, rather than mixing fixes into preservation. + +No PostgreSQL, browser integration, complete unit suite, live provider, or model +quality run was performed. The offline tests inject fetch stubs and dummy keys. +The TypeSafe documentation index and [HTTP API](https://docs.typesafe.ai/api) were +read without inference calls; this does not establish live service compatibility. +This dormant transport is not end-to-end Jev chat, a live provider compatibility +measurement, a quality baseline, or a verified release of the preserved prototype. + +No push, merge, paid calls, new database, migration execution, credential copies, +port-8787 handoff, app restart or other-worktree mutation is part of this milestone. +The old automatic goal remains paused. Next bounded slice: stored decision identity +(step 2), with question-package tests only. diff --git a/docs/jev-product-source-map.tsv b/docs/jev-product-source-map.tsv new file mode 100644 index 00000000..19787969 --- /dev/null +++ b/docs/jev-product-source-map.tsv @@ -0,0 +1,288 @@ +snapshot_path owner feature disposition +.env.example product configuration selective merge with current main; retain all relevant tests +apps/server/src/agent/decision-prose.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/decision-prose.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/heading-tool.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/heading-tool.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/job-scope.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/job-scope.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/planner.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/planner.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/refine-tool.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/refine-tool.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/revise-open-decision.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/revise-open-decision.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/tools.test.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/agent/tools.ts product planner-tools selective merge with current main; retain all relevant tests +apps/server/src/chat/address.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/address.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/job.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/notice.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/queue.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/references.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/references.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/service.test.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/chat/service.ts product chat-notices-jobs selective merge with current main; retain all relevant tests +apps/server/src/config.test.ts product configuration selective merge with current main; retain all relevant tests +apps/server/src/config.ts product configuration selective merge with current main; retain all relevant tests +apps/server/src/conversation-plan/cards-domain.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/cards.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/cards.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/corrections.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-provenance-acceptance.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-provenance.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d01-scoped-endorsement.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-deferral-domain.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-deferral-effects.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d02-team-deferral.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/d19-flow.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/domain.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/domain.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/effects.test.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/effects.ts product card-effects selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/events.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/interpret.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/interpret.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jev.test.ts product transport first slice +apps/server/src/conversation-plan/jev.ts product transport first slice +apps/server/src/conversation-plan/job-context.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-context.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-prompts.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/job-prompts.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jobs.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/jobs.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/linked-card-choice.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/pending-withdrawal.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/pipeline.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/planner-jobs.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/planner-jobs.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/policy.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/policy.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/preference.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/preference.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/prose-job.test.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/prose-job.ts product planner-jobs selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/qualified-choice.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/questions.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/questions.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quote-budget.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quotes.test.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/quotes.ts product interpretation-policy selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-consent.service.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-offer-detection.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-offers.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/research-task-snapshot.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/review.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/save-command.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/save-command.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/scoped-save-validation.test.ts product save-support-regressions selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/scripted-runner.test.ts product planner-regression-harness adapt test-only harness to current Planner +apps/server/src/conversation-plan/scripted-runner.ts product planner-regression-harness adapt test-only harness to current Planner +apps/server/src/conversation-plan/service-opening.test.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service-opening.ts product research-offers selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service.test.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/service.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/sources.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/conversation-plan/validation.ts product domain-processing selective port with all product regressions; preserve policy +apps/server/src/jobs/runner.test.ts product integration-review selective merge with current main; retain all relevant tests +apps/server/src/jobs/runner.ts product integration-review selective merge with current main; retain all relevant tests +apps/server/src/main.ts product runtime-wiring selective merge with current main; retain all relevant tests +apps/server/src/mcp/update-lifecycle.test.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/plan/edit.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/persistence.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/planner-jobs.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/projections.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/questionnaires.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/room.test.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/room.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/plan/service.ts product sidecar-projections selective merge with current main; retain all relevant tests +apps/server/src/questions.anchors.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions.card-meta.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions.service.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/anchors.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/anchors.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/backfill.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/backfill.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/card-actions.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/card-actions.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/conversation-card.service.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/option-match.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/prose.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/prose.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/question-option-source.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/records.reopen.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/records.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/reopen.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/service.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/store.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/store.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/write-prose.test.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/questions/write-prose.ts product decision-records selective merge with current main; retain all relevant tests +apps/server/src/research/service.test.ts product research-offers selective merge with current main; retain all relevant tests +apps/server/src/research/service.ts product research-offers selective merge with current main; retain all relevant tests +apps/server/src/socket/authorization.test.ts product authorization selective merge with current main; retain all relevant tests +apps/server/src/socket/authorization.ts product authorization selective merge with current main; retain all relevant tests +apps/server/src/storage/contract.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/memory/adapter.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/memory/research.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/model.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/port.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/adapter.test.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/adapter.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/migrations.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/research.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/storage/postgres/startup.test.ts product persistence selective merge with current main; retain all relevant tests +apps/server/src/tasks/plan-graphs.test.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/tasks/plan-graphs.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/server/src/testing/plan.ts product integration-regressions selective merge with current main; retain all relevant tests +apps/web/package.json product dependency-metadata selective merge with current main; retain all relevant tests +apps/web/src/chat/chat.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/decision-entry.test.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/decision-entry.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/markdown.test.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/model.test.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/model.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/references.ts product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/research-offer.test.ts product research-offer-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/research-offer.tsx product research-offer-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/scoped-choice-entry.tsx product save-suggestion-ui selective merge with current main; retain all relevant tests +apps/web/src/chat/transcript.test.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/chat/transcript.tsx product chat-transcript selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/analysis-overview.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/analysis-overview.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/card-parts.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence-popover.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence-popover.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/evidence.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/links.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/links.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/markers.test.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/markers.tsx product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/source.css product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/source.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/store.test.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/conversation-plan/store.ts product analysis-annotations selective merge with current main; retain all relevant tests +apps/web/src/design-audit/icons.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/resizable-pane.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/resizable-pane.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/room-workspace.tsx product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/theme.css product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/tokens.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace-model.test.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace-model.ts product chat-first-layout selective merge with current main; retain all relevant tests +apps/web/src/workspace.tsx product chat-first-layout selective merge with current main; retain all relevant tests +docs/self-hosting.md product configuration selective merge with current main; retain all relevant tests +e2e/chat-references.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-jobs.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-layout.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-prompts.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-runtime.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-stress.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/conversation-plan-ui.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/database.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-anchor-stress.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-evidence.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/decision-prose.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/editing.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/github.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-control.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-process.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev-wire.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/jev.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/planner-jobs.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/playwright.config.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-child-documents.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-child-surface.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offer-fixtures.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offers-ui.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/research-offers.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-content.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-decisions.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/responsive-workspace.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar-card-states.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar-navigation.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/sidecar.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +e2e/smoke.e2e.ts product browser-regressions selective merge with current main; retain all relevant tests +packages/dialect/src/dialect.test.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/dialect.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/index.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/limits.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/nodes/questionnaire.test.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/nodes/questionnaire.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/dialect/src/validate.ts product decision-dialect selective merge with current main; retain all relevant tests +packages/editor/src/card-meta.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/card-meta.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/comment-geometry.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/comment-geometry.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-format.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-format.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-layer.tsx product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-pin.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-pin.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-placement.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/decision-state.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-state.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-surface.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decision-surface.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decisions.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/decisions.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/evidence-geometry.test.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/evidence-geometry.ts product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/index.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/marks.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/marks.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/plan-editor.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/provider.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/provider.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/questionnaires.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/questionnaires.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/styles.css product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widget-options.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets-plugin.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/card-gap.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/card-gap.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/decision-deletion.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation-boundary.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation.test.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/discarded-navigation.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/evidence-hover.tsx product document-annotations selective merge with current main; retain all relevant tests +packages/editor/src/widgets/index.ts product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/questionnaire.test.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/editor/src/widgets/questionnaire.tsx product decision-editor selective merge with current main; retain all relevant tests +packages/icons/src/index.ts product card-icons selective merge with current main; retain all relevant tests +packages/icons/src/line.tsx product card-icons selective merge with current main; retain all relevant tests +packages/protocol/chat.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/conversation-plan.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/index.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/plan.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/protocol/question.d.ts product wire-contract selective merge with current main; retain all relevant tests +packages/question/src/answer.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/draft.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/index.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/limits.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/options.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/options.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/question.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/decision-parts.tsx product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/index.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/question-view.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/question-view.tsx product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/use-questionnaire.test.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/react/use-questionnaire.ts product decision-definition-draft selective merge with current main; retain all relevant tests +packages/question/src/schema.ts product decision-definition-draft selective merge with current main; retain all relevant tests +scripts/check-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/conversation-plan-demo.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-core.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-core.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-jev.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality-seed.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-card-quality.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-core.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-core.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev-stress.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/evaluate-jev.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/export-card-quality-snapshot.test.ts eval-hub runner-scoring-demo defer; preserve original in archive +scripts/export-card-quality-snapshot.ts eval-hub runner-scoring-demo defer; preserve original in archive From e9be4a0552e09d14832fab41e195f1a5d3df467d Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:52:06 +0100 Subject: [PATCH 002/152] Preserve stored decision identifiers during validation --- docs/jev-product-extraction.md | 42 +++++- packages/question/src/index.ts | 2 +- packages/question/src/question.test.ts | 201 +++++++++++++++++++++++++ packages/question/src/schema.ts | 54 +++++++ 4 files changed, 294 insertions(+), 5 deletions(-) diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 3a16ffeb..d90d6dee 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -1,7 +1,7 @@ -# Jev product extraction — first milestone +# Jev product extraction — handoff This is a finite extraction from a preserved prototype, not a verified release or -classifier-quality fix. Only the transport foundation is implemented in this milestone. +classifier-quality fix. The transport and stored-identity foundations are implemented in bounded slices. - Product branch: `maggie/jev-chat-product`. - Product worktree: `/Users/maggieappleton/.codex/worktrees/jev-chat-product/chopin`. @@ -159,5 +159,39 @@ measurement, a quality baseline, or a verified release of the preserved prototyp No push, merge, paid calls, new database, migration execution, credential copies, port-8787 handoff, app restart or other-worktree mutation is part of this milestone. -The old automatic goal remains paused. Next bounded slice: stored decision identity -(step 2), with question-package tests only. +The old automatic goal remains paused. Continued work below belongs to this product +extraction chat; it does not resume the old prototype goal. + +## Stored decision identity slice + +Extract `storedText()` and `identified()` unchanged from the preserved schema and +export the validator publicly. It validates durable IDs, order and raw text lengths +without re-keying, trimming, freezing or mutating a definition. Current `normalize()`, +shared drafts and answer derivation retain their existing behavior. + +The two archived validator tests are retained with the pending-card validator portion. +Its draft-mode and unanswered-derive assertions depend on the later draft slice and +remain explicitly pending; changing draft behavior here would exceed this boundary. +Five additional tests cover stable IDs/order/identity, duplicate IDs across questions, +unknown fields, counts, and every raw text field. Test-first verification failed on the +missing public export, then passed after extraction. Independent review approved the +slice: 33 question-package tests passed, 0 failed (99 assertions), on Bun 1.4.2. +Full workspace types passed; formatting and CI checks passed with existing warnings. +The validator has no runtime consumers yet; durable restoration integration is pending. + +## Product tests with eval dependencies + +Seven archived product tests currently read eval development fixtures or helpers: +`d01-provenance-acceptance.test.ts`, `d01-scoped-endorsement.test.ts`, +`d02-deferral-domain.test.ts`, `d02-deferral-effects.test.ts`, +`d02-team-deferral.test.ts`, `d19-flow.test.ts`, and `quotes.test.ts` under +`apps/server/src/conversation-plan/`. Keep their assertions with the product slices. +Extract only the test messages, injected model replies and narrow state observations +needed by those assertions into product test fixtures/helpers with source provenance. +Do not import the eval runner/scorer/dataset tree or skip these tests. Original material +stays archived; reserved examples stay sealed. This adaptation is required before +claiming those slices' product regression coverage passes independently of eval tooling. + +Next bounded pure-state sub-slice: archived conversation namespace and exact source +provenance validation, before event replay/domain. Keep the namespace outside global +wire unions until authorized producers/handlers/consumers are ported together. diff --git a/packages/question/src/index.ts b/packages/question/src/index.ts index 3ca45f27..02a5c5f7 100644 --- a/packages/question/src/index.ts +++ b/packages/question/src/index.ts @@ -12,7 +12,7 @@ export * as limits from "./limits"; -export { assertCallId, decision, normalize, QuestionError, reject } from "./schema"; +export { assertCallId, decision, identified, normalize, QuestionError, reject } from "./schema"; export type { Answer, DecisionDefinition, Definition, Item, Option } from "./schema"; export { answered, apply, assertPatch, create, read, restore } from "./draft"; diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index 2d3c929e..8e5078cc 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test"; import { derive, incomplete, summarize } from "./answer"; import { answered, assertPatch, create, read } from "./draft"; import * as limits from "./limits"; +import { identified } from "./index"; import { normalize, QuestionError } from "./schema"; import type { Definition } from "./schema"; @@ -63,6 +64,206 @@ describe("normalize", () => { }); }); +describe("identified", () => { + it("preserves stored IDs, text, and order without copying the definition", () => { + let source = { + questions: [{ + id: "question-z", + header: " Rollout ", + question: "How?", + multiple: false, + options: [ + { id: "option-z", label: "Canary", description: "" }, + { id: "option-a", label: "Blue-green", description: "" }, + ], + }, { + id: "question-a", + header: "Timing", + question: "When?", + multiple: false, + options: [{ id: "option-t", label: "Today", description: "" }], + }], + }; + let before = structuredClone(source); + expect(identified(source)).toBe(source); + expect(source).toEqual(before); + expect(source.questions.map(question => question.id)).toEqual(["question-z", "question-a"]); + expect(source.questions[0]!.options.map(option => option.id)).toEqual(["option-z", "option-a"]); + }); + it("accepts a pending single-choice card with no quoted options but keeps tool input strict", () => { + let pending = { + questions: [{ + id: "q1", + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: [], + }], + }; + expect(identified(pending)).toBe(pending); + expect(() => normalize(tool({ options: [] }))).toThrow(/at least one option/); + expect(() => + identified({ + questions: [{ ...pending.questions[0], multiple: true }], + }) + ).toThrow(/invalid options/); + }); + + it("keeps stable IDs and refuses oversized raw values with surrounding spaces", () => { + let source = { + questions: [{ + id: "q1", + header: "Rollout", + question: "How?", + multiple: false, + options: [{ id: "o1", label: "Canary", description: "" }], + }], + }; + expect(identified(source)).toBe(source); + expect(() => + identified({ + questions: [{ ...source.questions[0], id: ` ${"q".repeat(limits.MAX_CALL_ID)} ` }], + }) + ).toThrow(/exceeds/); + expect(() => + identified({ + questions: [{ ...source.questions[0], header: ` ${"x".repeat(limits.MAX_HEADER)} ` }], + }) + ).toThrow(/exceeds/); + expect(() => + identified({ + questions: [{ + ...source.questions[0], + options: [{ + ...source.questions[0]!.options[0], + label: ` ${"x".repeat(limits.MAX_LABEL)} `, + }], + }], + }) + ).toThrow(/exceeds/); + }); + + it("rejects duplicate question IDs and option IDs across the questionnaire", () => { + let question = { + id: "q1", + header: "Rollout", + question: "How?", + multiple: false, + options: [{ id: "o1", label: "Canary", description: "" }], + }; + expect(() => identified({ questions: [question, { ...question }] })) + .toThrow(/duplicate question IDs/); + expect(() => identified({ questions: [question, { ...question, id: "q2" }] })) + .toThrow(/duplicate option IDs/); + expect(() => + identified({ + questions: [{ ...question, options: [...question.options, ...question.options] }], + }) + ) + .toThrow(/duplicate option IDs/); + }); + + it("rejects unknown fields at every stored definition level", () => { + let question = { + id: "q1", + header: "Rollout", + question: "How?", + multiple: false, + options: [{ id: "o1", label: "Canary", description: "" }], + }; + for ( + let source of [ + { questions: [question], extra: true }, + { questions: [{ ...question, extra: true }] }, + { questions: [{ ...question, options: [{ ...question.options[0], extra: true }] }] }, + ] + ) expect(() => identified(source)).toThrow(/invalid fields/); + }); + + it("enforces question and option counts and rejects empty options for multiple questions", () => { + let question = { + id: "q1", + header: "Rollout", + question: "How?", + multiple: false, + options: [{ id: "o1", label: "Canary", description: "" }], + }; + let questions = Array.from({ length: limits.MAX_QUESTIONS }, (_, index) => ({ + ...question, + id: `q${index}`, + options: [{ ...question.options[0], id: `o${index}` }], + })); + expect(identified({ questions }).questions).toHaveLength(limits.MAX_QUESTIONS); + expect(() => identified({ questions: [] })).toThrow(/invalid question count/); + expect(() => identified({ questions: [...questions, { ...question, id: "extra" }] })) + .toThrow(/invalid question count/); + let options = Array.from({ length: limits.MAX_OPTIONS }, (_, index) => ({ + ...question.options[0], + id: `o${index}`, + })); + expect(identified({ questions: [{ ...question, options }] }).questions[0]!.options) + .toHaveLength(limits.MAX_OPTIONS); + expect(() => + identified({ + questions: [{ + ...question, + options: [...options, { id: "extra", label: "Extra", description: "" }], + }], + }) + ) + .toThrow(/invalid options/); + expect(() => + identified({ questions: [{ ...question, options: [] }, { ...question, id: "q2" }] }) + ) + .toThrow(/invalid options/); + expect(() => identified({ questions: [{ ...question, multiple: "yes" }] })) + .toThrow(/invalid options/); + }); + + it("bounds every raw text field and rejects blank identifiers and required text", () => { + let question = { + id: "q1", + header: "Rollout", + question: "How?", + multiple: false, + options: [{ id: "o1", label: "Canary", description: "" }], + }; + for ( + let [field, maximum] of [ + ["id", limits.MAX_CALL_ID], + ["header", limits.MAX_HEADER], + ["question", limits.MAX_QUESTION], + ] as const + ) { + expect(() => identified({ questions: [{ ...question, [field]: " " }] })).toThrow( + QuestionError, + ); + expect(() => + identified({ questions: [{ ...question, [field]: ` ${"x".repeat(maximum)} ` }] }) + ) + .toThrow(/exceeds/); + } + for ( + let [field, maximum] of [ + ["id", limits.MAX_CALL_ID], + ["label", limits.MAX_LABEL], + ["description", limits.MAX_DESCRIPTION], + ] as const + ) { + let options = [{ ...question.options[0], [field]: ` ${"x".repeat(maximum)} ` }]; + expect(() => identified({ questions: [{ ...question, options }] })).toThrow(/exceeds/); + if (field !== "description") { + expect(() => + identified({ + questions: [{ ...question, options: [{ ...question.options[0], [field]: " " }] }], + }) + ) + .toThrow(QuestionError); + } + } + }); +}); + describe("draft", () => { let definition: Definition = normalize(tool()); diff --git a/packages/question/src/schema.ts b/packages/question/src/schema.ts index 659253bd..95378f94 100644 --- a/packages/question/src/schema.ts +++ b/packages/question/src/schema.ts @@ -50,6 +50,12 @@ function text(value: unknown, name: string, max: number, optional = false): stri return result; } +/** A stored definition keeps its original string, so enforce the bound on that string. */ +function storedText(value: unknown, name: string, max: number, optional = false): void { + text(value, name, max, optional); + if ((value as string).length > max) fail(`${name} exceeds ${max} characters`); +} + /** * Validate an agent's tool input into a frozen definition. * @@ -121,6 +127,54 @@ export function normalize(raw: unknown): Definition { return Object.freeze({ questions }); } +/** Validate a stored or wire definition without changing its durable IDs or order. */ +export function identified(raw: unknown): Definition { + let source = record(raw, "Questionnaire definition"); + exact(source, ["questions"], "Questionnaire definition"); + if ( + !Array.isArray(source.questions) || source.questions.length === 0 + || source.questions.length > limits.MAX_QUESTIONS + ) { + fail("Questionnaire definition has an invalid question count"); + } + let questionIds = new Set(); + let optionIds = new Set(); + for (let [index, candidate] of source.questions.entries()) { + let question = record(candidate, `Question ${index + 1}`); + exact(question, ["id", "header", "question", "options", "multiple"], `Question ${index + 1}`); + storedText(question.id, `Question ${index + 1} id`, limits.MAX_CALL_ID); + let id = question.id as string; + if (questionIds.has(id)) fail("Questionnaire has duplicate question IDs"); + questionIds.add(id); + storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER); + storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION); + if ( + typeof question.multiple !== "boolean" + || !Array.isArray(question.options) + || question.options.length === 0 && (source.questions.length !== 1 || question.multiple) + || question.options.length > limits.MAX_OPTIONS + ) { + fail(`Question ${index + 1} has invalid options or multiple value`); + } + for (let [position, candidate] of question.options.entries()) { + let option = record(candidate, `Question ${index + 1} option ${position + 1}`); + exact(option, ["id", "label", "description"], `Question ${index + 1} option ${position + 1}`); + storedText(option.id, `Question ${index + 1} option id`, limits.MAX_CALL_ID); + let optionId = option.id as string; + if (optionIds.has(optionId)) fail("Questionnaire has duplicate option IDs"); + optionIds.add(optionId); + storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL); + storedText( + option.description, + `Question ${index + 1} option description`, + limits.MAX_DESCRIPTION, + true, + ); + } + } + return raw as Definition; +} + /** Narrow a questionnaire to the shape owned by one durable decision card. */ export function decision(definition: Definition): DecisionDefinition { if (definition.questions.length !== 1) { From 7e63f4a7b098e439647204de8c4e34fe7839cfc4 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:54:33 +0100 Subject: [PATCH 003/152] Extract conversation contract and source provenance validation --- .../src/conversation-plan/sources.test.ts | 135 ++++++ apps/server/src/conversation-plan/sources.ts | 67 +++ docs/jev-product-extraction.md | 27 +- packages/protocol/conversation-plan.d.ts | 440 ++++++++++++++++++ packages/protocol/index.d.ts | 1 + 5 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/conversation-plan/sources.test.ts create mode 100644 apps/server/src/conversation-plan/sources.ts create mode 100644 packages/protocol/conversation-plan.d.ts 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index d90d6dee..1d1461bf 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -1,7 +1,7 @@ # Jev product extraction — handoff This is a finite extraction from a preserved prototype, not a verified release or -classifier-quality fix. The transport and stored-identity foundations are implemented in bounded slices. +classifier-quality fix. The transport, stored-identity and source-provenance foundations are implemented in bounded slices. - Product branch: `maggie/jev-chat-product`. - Product worktree: `/Users/maggieappleton/.codex/worktrees/jev-chat-product/chopin`. @@ -192,6 +192,25 @@ Do not import the eval runner/scorer/dataset tree or skip these tests. Original stays archived; reserved examples stay sealed. This adaptation is required before claiming those slices' product regression coverage passes independently of eval tooling. -Next bounded pure-state sub-slice: archived conversation namespace and exact source -provenance validation, before event replay/domain. Keep the namespace outside global -wire unions until authorized producers/handlers/consumers are ported together. +## Conversation contract and source provenance slice + +Extract the complete archived `ConversationPlan` namespace and `sources.ts` unchanged. +The public namespace type export preserves all Save, research, correction and event +cases. Global Incoming/Outgoing unions, session flags and runtime routes are unchanged; +wire activation waits for authorized producers/handlers/consumers together. + +Six focused source tests cover exact UTF-16 spans (including emoji), completed saved +member/agent messages, source identity, text and author matching, streaming rejection, +unknown fields, missing fields, bounds and role allowlist. Test-first verification +failed on the missing module before extraction. Existing handle-length and enumerable +property shape quirks remain inherited; no classifier or validation behavior was tuned. +Independent review approved the slice: six source tests passed, 0 failed (53 +assertions), and the protocol type check passed on Bun 1.4.2. The implementer also +verified the 39 combined source/question tests (152 assertions). Full workspace types, +formatting and CI passed with the same existing warnings. These checks establish +standalone provenance validation, not live conversation integration. + +Next bounded interpretation sub-slice: exact quote extraction and its four archived +regression tests. Extract only the five development messages those tests require, +with archive/source mapping, rather than importing evaluation datasets. Pure event +replay/domain and durable processing remain later dependencies. diff --git a/packages/protocol/conversation-plan.d.ts b/packages/protocol/conversation-plan.d.ts new file mode 100644 index 00000000..7e523696 --- /dev/null +++ b/packages/protocol/conversation-plan.d.ts @@ -0,0 +1,440 @@ +import type { Chat } from "./chat"; +import type { Frame, Request } from "./index"; + +type KIND = Frame & { kind: K }; + +/** The prototype's sidecar and wire contract. Accepted events are its domain authority. */ +export declare namespace ConversationPlan { + export type Incoming = + | Request + | Request + | Request + | Request + | Request + | Request; + export type Outgoing = + | Snapshot + | Changed + | Corrected + | SavedScopedChoice + | Retried + | Jobs + | RetriedJob + | ResearchConsentResult + | ResearchLinkResult; + + export type JobKind = "heading" | "refine" | "suggest" | "prose"; + /** One durable piece of background Planner work. */ + export type Job = { + id: string; + kind: JobKind; + /** Questionnaire id, or "document" for a heading job. */ + target: string; + /** Chat message or decision that caused this job. */ + trigger: string; + status: "pending" | "running" | "done" | "failed" | "skipped"; + attempts: number; + reason?: string; + /** Short JSON summary of the tool result. */ + output?: string; + /** ISO 8601 time of the last status change. */ + at: string; + }; + + export type SourceAuthor = Extract; + export type Actor = SourceAuthor | { kind: "classifier" }; + /** A withdrawal source supports only a neutral stance that retracts its speaker's pending choice. */ + export type SourceRole = + | "question" + | "option" + | "reason" + | "constraint" + | "support" + | "objection" + | "withdrawal" + | "verification" + | "resolution" + | "reopening"; + + /** UTF-16 offsets into a saved, complete Chat entry. */ + export type SourceRef = { + messageId: string; + author: SourceAuthor; + quote: string; + start: number; + end: number; + role: SourceRole; + }; + /** Exact source span for a Research offer, without a classifier contribution role. */ + export type ResearchSource = Omit; + export type ResearchAction = { + id: string; + kind: "research" | "dismiss"; + /** Supplied by the authorized caller, not by the classifier or client payload. */ + actor: Extract; + /** Stable verified GitHub user ID for an idempotent research request. */ + principalId: string; + at: number; + }; + /** Frozen option wording and event prefix for one bounded current-cost task. */ + export type ResearchTask = + | { + kind: "current-cost-comparison"; + threadId: string; + /** Exact accepted-event prefix and thread version when these labels were captured. */ + observedEventCount: number; + observedThreadVersion: number; + options: [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }]; + } + | { + kind: "current-cost-concern"; + threadId: string; + observedEventCount: number; + observedThreadVersion: number; + /** Every current option, in thread order; no pair is inferred from the concern. */ + options: + | [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }, { + id: string; + labelAtOffer: string; + }] + | [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }, { + id: string; + labelAtOffer: string; + }, { id: string; labelAtOffer: string }]; + /** Only a unique option named by the exact source quote may be focused. */ + focusOptionId?: string; + }; + export type ResearchOffer = { + id: string; + /** Opaque semantic identity; needId with contextId suppresses repeat offers. */ + needId: string; + contextId: string; + source: ResearchSource; + /** Absent on earlier offers, whose brief is the exact source quote. */ + task?: ResearchTask; + /** Deterministic public-worker brief and displayed wording. */ + brief: string; + threadId?: string; + status: "offered" | "dismissed" | "accepted"; + action?: ResearchAction; + }; + + export type Authoring = "quoted" | "scribe" | "human-edited"; + export type ThreadStatus = "exploring" | "leaning" | "decided" | "reopened" | "discarded"; + export type Contribution = { + id: string; + kind: "option" | "reason" | "constraint"; + text: string; + /** Concise card wording; text and sources remain the original evidence. */ + displayLabel?: string; + targetId?: string; + relation?: "supports" | "challenges" | "qualifies"; + authoring: Authoring; + editedBy?: string; + targetEditedBy?: string; + sources: SourceRef[]; + actor: Actor; + }; + export type Stance = { + id: string; + participant: string; + optionId?: string; + position: "support" | "oppose" | "neutral"; + sources: SourceRef[]; + at: number; + corrects?: string; + correctedBy?: string; + }; + export type Decision = { + id: string; + text: string; + optionId?: string; + sources: SourceRef[]; + actor: Extract; + editedBy?: string; + at: number; + }; + export type Candidate = { + id: string; + kind: "resolution" | "reopening"; + text: string; + sources: SourceRef[]; + status: "pending" | "confirmed" | "rejected"; + actedBy?: string; + }; + export type Thread = { + id: string; + question: string; + questionSources: SourceRef[]; + questionAuthoring: Authoring; + questionEditedBy?: string; + status: ThreadStatus; + contributions: Contribution[]; + /** Latest explicit stance for each participant and option. */ + stances: Stance[]; + stanceHistory: Stance[]; + decision?: Decision; + decisionHistory: Decision[]; + candidates: Candidate[]; + /** The decision card that shows this thread in the document. */ + questionnaireId?: string; + /** The latest proposal to settle, until a person decides or it is superseded. */ + pendingSettle?: { optionId: string; proposer: string; messageId: string }; + /** A provisional choice for a spike; this never decides the card. */ + pendingScopedChoice?: { + /** Absent only on snapshots written before scoped agreements existed. */ + proposalId?: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + proposer: string; + messageId: string; + }; + version: number; + }; + + export type EventBase = { + id: string; + threadId: string; + observedThreadVersion: number; + origin: "classifier" | "planner" | "human"; + actor: Actor; + at: number; + }; + export type ScopedChoiceSave = { + actionId: string; + threadId: string; + expectedVersion: number; + proposalId: string; + cardId: string; + optionId: string; + expectedLabel?: string; + expectedGeneration: number; + }; + export type Event = + | (EventBase & { type: "thread.opened"; source?: SourceRef; question: string }) + | (EventBase & { + type: "option.added" | "reason.added" | "constraint.added"; + /** Required for sourced chat inference; absent for human and Planner card actions. */ + source?: SourceRef; + contribution: Pick; + }) + | (EventBase & { + type: "stance.changed"; + source: SourceRef; + optionId?: string; + /** Null means reviewed without a scoped target; absent only on persisted legacy events. */ + scopedProposalId?: string | null; + position: Stance["position"]; + }) + | (EventBase & { + type: "option.relabeled"; + optionId: string; + label: string; + observedCardRevision: number; + }) + | (EventBase & { type: "thread.leaning"; source: SourceRef; optionId?: string }) + | (EventBase & { type: "card.linked"; questionnaireId: string }) + | (EventBase & { type: "settle.suggested"; source: SourceRef; optionId: string }) + | (EventBase & { type: "settle.agreed"; source: SourceRef; optionId: string }) + | (EventBase & { type: "settle.deferred"; source: SourceRef; proposalId: string }) + | (EventBase & { + type: "settle.resumed"; + source: SourceRef; + proposalId: string; + deferredEventId: string; + }) + | (EventBase & { + type: "scoped-choice.proposed"; + source: SourceRef; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + }) + | (EventBase & { + type: "scoped-choice.agreed"; + source: SourceRef; + proposalId: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + }) + | (EventBase & { + type: "scoped-choice.saved"; + proposalId: string; + /** New saves bind each current support source to its accepted proposal or agreement event. */ + supportEventIds?: string[]; + /** Present on legacy proposal-plus-one-agreement saves. */ + agreementId?: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + sources: SourceRef[]; + expectedGeneration: number; + expectedLabel?: string; + }) + | (EventBase & { type: "thread.discarded" }) + | (EventBase & { + type: "decision.recorded"; + source?: SourceRef; + text: string; + optionId?: string; + explicit: true; + }) + | (EventBase & { type: "decision.reopened"; source?: SourceRef; explicit: true }) + | (EventBase & { + type: "candidate.proposed"; + source: SourceRef; + candidate: Pick; + }) + | (EventBase & { + type: "candidate.confirmed" | "candidate.rejected"; + candidateId: string; + }) + | (EventBase & { + type: "card.corrected"; + change: EditChange | MoveChange | StatusChange | RetargetChange; + }); + + export type EditChange = + | { kind: "edit"; field: "question" | "decision"; text: string; contributionId?: never } + | { kind: "edit"; field: "contribution"; text: string; contributionId: string }; + export type MoveChange = { + kind: "move"; + contributionId: string; + targetThreadId: string; + targetVersion: number; + }; + export type StatusChange = { + kind: "set-status"; + status: Exclude; + }; + export type RetargetChange = + | { kind: "retarget-stance"; stanceId: string; optionId?: string } + | { kind: "dismiss-stance"; stanceId: string } + | { kind: "retarget-contribution"; contributionId: string; targetId: string }; + export type CorrectionChange = + | EditChange + | MoveChange + | StatusChange + | RetargetChange + | { + kind: "add-excerpt"; + messageId: string; + start: number; + end: number; + contributionKind: "option" | "reason" | "constraint"; + targetOptionId?: string; + } + | { kind: "record-decision"; text: string; optionId?: string } + | { kind: "confirm-candidate" | "reject-candidate"; candidateId: string }; + export type CorrectionAction = { + /** Stable across client retries, independent of the request's rid. */ + actionId: string; + threadId: string; + expectedVersion: number; + change: CorrectionChange; + }; + + export type QueueItem = { + messageId: string; + status: "pending" | "processing" | "failed"; + attempts: number; + error?: string; + }; + export type Distribution = { [answer: string]: number }; + export type AnalysisAnswer = + | { type: "noul"; noul: number } + | { type: "choice"; choice: string; confidence: number; probabilities: Distribution } + | { + type: "score"; + score: number; + confidence: number; + legend: { [level: string]: string }; + probabilities: Distribution; + }; + export type AnalysisPass = + | { stage: "triage" | "targeting"; answers: { [question: string]: AnalysisAnswer } } + | { + stage: "clarification"; + version: "bare-editor-clarification-1"; + answers: { [question: string]: AnalysisAnswer }; + }; + export type CandidateOutcome = { + start: number; + end: number; + status: "accepted" | "review" | "ignored"; + gate: string; + targetId?: string; + eventIds: string[]; + }; + export type AnalysisRecord = { + messageId: string; + questionSetVersion: string; + modelVersion: string; + status: "queued" | "running" | "applied" | "unlinked" | "failed"; + passes: AnalysisPass[]; + selectedTarget?: string; + quoteValidation?: Array<{ start: number; end: number; valid: boolean }>; + outcomes?: CandidateOutcome[]; + policyGate?: string; + candidates?: Array<{ id: string; kind: Candidate["kind"]; targetId?: string }>; + eventIds: string[]; + latencyMs?: number; + error?: string; + }; + export type State = { + schemaVersion: 1; + revision: number; + events: Event[]; + threads: Thread[]; + queue: QueueItem[]; + /** Bounded diagnostics; accepted event IDs remain in events. */ + analysis: AnalysisRecord[]; + /** Optional for version-1 sidecars saved before research offers existed. */ + researchOffers?: ResearchOffer[]; + }; + + export type Correct = KIND<"conversation-plan:correct"> & CorrectionAction; + export type SaveScopedChoice = KIND<"conversation-plan:scoped-choice-save"> & ScopedChoiceSave; + export type Retry = KIND<"conversation-plan:retry"> & { actionId: string; messageId: string }; + export type ResearchConsent = + & KIND<"conversation-plan:research"> + & ( + | { offerId: string; actionId: string; choice: "research" | "dismiss" } + | { offerId: string; choice: "resume"; actionId?: never } + ); + export type ResearchConsentResult = KIND<"conversation-plan:research"> & { + offerId: string; + status: ResearchOffer["status"]; + revision: number; + execution: "none" | "pending-owner" | "pending-retry" | "started"; + /** Present when this attempt returned an already placed research request. */ + researchRequestId?: string; + }; + export type ResearchLink = KIND<"conversation-plan:research-link"> & { offerId: string }; + export type ResearchLinkResult = KIND<"conversation-plan:research-link"> & { + offerId: string; + status: "pending" | "unlinked" | "linked"; + /** Present for an existing exact request, including before its first job is linked. */ + researchRequestId?: string; + }; + export type Snapshot = KIND<"conversation-plan:snapshot"> & { state: State; jobs?: Job[] }; + export type Changed = KIND<"conversation-plan:changed"> & { state: State }; + export type Jobs = KIND<"conversation-plan:jobs"> & { jobs: Job[] }; + export type Corrected = KIND<"conversation-plan:correct"> & { eventId: string; revision: number }; + export type SavedScopedChoice = KIND<"conversation-plan:scoped-choice-save"> & { + eventId: string; + revision: number; + }; + export type Retried = KIND<"conversation-plan:retry"> & { messageId: string; queued: boolean }; + export type RetryJob = KIND<"conversation-plan:retry-job"> & { jobId: string }; + export type RetriedJob = KIND<"conversation-plan:retry-job"> & { + jobId: string; + queued: boolean; + }; +} diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index 252cef77..d31c0b51 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -111,6 +111,7 @@ export declare namespace Session { export type { Chat } from "./chat"; export type { Comment } from "./comment"; +export type { ConversationPlan } from "./conversation-plan"; export type { Job } from "./job"; export type { Plan } from "./plan"; export type { Question } from "./question"; From 52662d9c181e45c494d22b1e8f31661452a8d9a6 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:57:27 +0100 Subject: [PATCH 004/152] Extract exact quote parsing with product regression fixtures --- .../src/conversation-plan/quote-budget.ts | 7 + .../conversation-plan/quotes.test-fixtures.ts | 21 ++ .../src/conversation-plan/quotes.test.ts | 82 +++++ apps/server/src/conversation-plan/quotes.ts | 323 ++++++++++++++++++ docs/jev-product-extraction.md | 26 +- 5 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/conversation-plan/quote-budget.ts create mode 100644 apps/server/src/conversation-plan/quotes.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/quotes.test.ts create mode 100644 apps/server/src/conversation-plan/quotes.ts 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..2a201d53 --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.test.ts @@ -0,0 +1,82 @@ +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"); +}); diff --git a/apps/server/src/conversation-plan/quotes.ts b/apps/server/src/conversation-plan/quotes.ts new file mode 100644 index 00000000..248f7e2e --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.ts @@ -0,0 +1,323 @@ +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()); +} + +/** 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 topicAlternatives = trimmed.length <= 500 + ? trimmed.match( + /^[Ww]hat sends [\p{L}][\p{L}-]{2,39}\s+[\p{L}][\p{L}-]{2,39}(?:\s+(?:when configured|as a fallback))?\?\s+our\s+([A-Z][\p{L}\p{N}.+#-]{1,39}(?:\s+[\p{L}\p{N}][\p{L}\p{N}.+#-]{1,39})?),\s+([A-Z][\p{L}\p{N}.+#-]{1,39}(?:\s+[\p{L}\p{N}][\p{L}\p{N}.+#-]{1,39})?),\s+or\s+([A-Z][\p{L}\p{N}.+#-]{1,39}(?:\s+[\p{L}\p{N}][\p{L}\p{N}.+#-]{1,39})?)\?$/u, + ) + : null; + if (topicAlternatives) { + let labels = topicAlternatives.slice(1).map(label => label.trim().toLowerCase()); + if (new Set(labels).size !== 3 || labels.some(startsWithClauseHead)) return []; + } + if (!topicAlternatives && /[.!;]/.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 = topicAlternatives ?? 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; +} + +/** A bare editor list whose final native-editor alternative contains its own "and". */ +export function bareEditorOptionQuotes(text: string): QuoteCandidate[] { + if (text.length > 500) return []; + let match = text.match( + /^([\p{L}][\p{L}\p{N}.+/-]{1,39}), (bare [\p{L}][\p{L}\p{N}.+/-]{1,39}), ([\p{L}][\p{L}\p{N}.+/-]{1,39}), or (just native Selection and Range with our own document model)\.$/iu, + ); + if (!match) return []; + let labels = match.slice(1); + let names = labels.map(label => label.replace(/^bare\s+/i, "").toLowerCase()); + if (new Set(names).size !== 4) 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 isBareEditorOptionList( + text: string, + candidates: readonly QuoteCandidate[], +): boolean { + let exact = bareEditorOptionQuotes(text); + return exact.length === 4 && candidates.length === 4 + && exact.every((quote, index) => + quote.quote === candidates[index]?.quote + && quote.start === candidates[index]?.start + && quote.end === candidates[index]?.end + ); +} + +/** 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 direct = directAlternativeQuotes(text); + if (direct.length === 3) return direct; + let bareEditor = bareEditorOptionQuotes(text); + if (bareEditor.length) return bareEditor; + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 1d1461bf..175e050b 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -1,7 +1,7 @@ # Jev product extraction — handoff This is a finite extraction from a preserved prototype, not a verified release or -classifier-quality fix. The transport, stored-identity and source-provenance foundations are implemented in bounded slices. +classifier-quality fix. The transport, stored-identity, source-provenance and quote foundations are implemented in bounded slices. - Product branch: `maggie/jev-chat-product`. - Product worktree: `/Users/maggieappleton/.codex/worktrees/jev-chat-product/chopin`. @@ -210,7 +210,23 @@ verified the 39 combined source/question tests (152 assertions). Full workspace formatting and CI passed with the same existing warnings. These checks establish standalone provenance validation, not live conversation integration. -Next bounded interpretation sub-slice: exact quote extraction and its four archived -regression tests. Extract only the five development messages those tests require, -with archive/source mapping, rather than importing evaluation datasets. Pure event -replay/domain and durable processing remain later dependencies. +## Exact quote extraction slice + +The parser and quote budget are byte-identical to the archive. All four original +quote test bodies and assertions remain; five exact development messages now live in +`quotes.test-fixtures.ts` with archive SHA, case, step and source path. No labels, +scoring or dataset imports accompany them. Root and reviewer checked text parity, +including apostrophes and UTF-16 offsets. A fifth regression verifies that four +candidates succeed and a fifth throws rather than silently losing source evidence. + +Test-first verification failed on the missing parser module before extraction. +Independent review approved the slice: 5 tests passed, 0 failed (20 assertions), on +Bun 1.4.2. Full workspace types, formatting and CI passed with existing warnings. +This resolves `quotes.test.ts`'s eval fixture dependency; the other six listed product +test adaptations remain pending. No parsing rules or classifier policy changed. + +Next bounded pure-state sub-slice: event/correction shape validation. Keep its archived +function bodies and strictness, extracting event and correction modules plus shared +field guards behind the existing `validation.ts` entry point. This preserves behavior +while avoiding importing the entire 855-line validation file at once. Research/state +validators, event replay/domain and durable processing remain later dependencies. From 38f3476ec0ea732121d63044c519586621255c2f Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:02:04 +0100 Subject: [PATCH 005/152] Extract event and correction guards without changing policy --- .../correction-validation.ts | 90 +++++ .../src/conversation-plan/event-validation.ts | 304 ++++++++++++++++ .../conversation-plan/validation-fields.ts | 53 +++ .../src/conversation-plan/validation.test.ts | 342 ++++++++++++++++++ .../src/conversation-plan/validation.ts | 11 + docs/jev-product-extraction.md | 23 +- 6 files changed, 818 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/conversation-plan/correction-validation.ts create mode 100644 apps/server/src/conversation-plan/event-validation.ts create mode 100644 apps/server/src/conversation-plan/validation-fields.ts create mode 100644 apps/server/src/conversation-plan/validation.test.ts create mode 100644 apps/server/src/conversation-plan/validation.ts 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/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/validation-fields.ts b/apps/server/src/conversation-plan/validation-fields.ts new file mode 100644 index 00000000..d02e01b8 --- /dev/null +++ b/apps/server/src/conversation-plan/validation-fields.ts @@ -0,0 +1,53 @@ +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..d49979b0 --- /dev/null +++ b/apps/server/src/conversation-plan/validation.ts @@ -0,0 +1,11 @@ +export { assertCorrectionAction } from "./correction-validation"; +export { assertEventShape } from "./event-validation"; +export { + MAX_ANALYSIS, + MAX_EVENTS, + MAX_QUEUE, + MAX_RESEARCH_OFFERS, + MAX_THREADS, + spikeAgreementLabel, + spikePreferenceLabel, +} from "./validation-fields"; diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 175e050b..26457e8e 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -225,8 +225,21 @@ Bun 1.4.2. Full workspace types, formatting and CI passed with existing warnings This resolves `quotes.test.ts`'s eval fixture dependency; the other six listed product test adaptations remain pending. No parsing rules or classifier policy changed. -Next bounded pure-state sub-slice: event/correction shape validation. Keep its archived -function bodies and strictness, extracting event and correction modules plus shared -field guards behind the existing `validation.ts` entry point. This preserves behavior -while avoiding importing the entire 855-line validation file at once. Research/state -validators, event replay/domain and durable processing remain later dependencies. +## Event and correction validation slice + +The archived guard functions now live in `event-validation.ts`, +`correction-validation.ts` and the leaf `validation-fields.ts`; `validation.ts` +reexports their original public APIs and five bounds. Dependencies are acyclic. +Independent review and root syntax-tree comparison verified all 11 function bodies +and five constants against the archive; every event/correction case remains. + +Independent Bun 1.4.2 tests: 7 passed, 0 failed (88 assertions). Full workspace types +passed. These checks exercise field guards, explicit/human/source requirements, +bounds, correction variants and scoped support identities. They do not establish +saved-message provenance or replayed domain validity. The inherited `card.corrected` +acceptance of `add-excerpt`, despite its narrower protocol union, remains unchanged. +Research/state validators are still pending rather than silently omitted. + +Next bounded pure-state sub-slice: research-offer and snapshot guards in separate +modules, preserving the remaining archived validator functions through the façade. +Event replay/domain and durable processing follow those guards. From 86dca575b6e6ab2aa21c1309d8b4a8e520427f0d Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:07:34 +0100 Subject: [PATCH 006/152] Extract research-offer and snapshot guards --- .../research-validation.test.ts | 153 ++++++++++ .../conversation-plan/research-validation.ts | 174 ++++++++++++ .../state-validation.test.ts | 237 ++++++++++++++++ .../src/conversation-plan/state-validation.ts | 265 ++++++++++++++++++ .../src/conversation-plan/validation.ts | 6 + docs/jev-product-extraction.md | 22 +- 6 files changed, 852 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/conversation-plan/research-validation.test.ts create mode 100644 apps/server/src/conversation-plan/research-validation.ts create mode 100644 apps/server/src/conversation-plan/state-validation.test.ts create mode 100644 apps/server/src/conversation-plan/state-validation.ts 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/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.ts b/apps/server/src/conversation-plan/validation.ts index d49979b0..4499b68c 100644 --- a/apps/server/src/conversation-plan/validation.ts +++ b/apps/server/src/conversation-plan/validation.ts @@ -1,5 +1,11 @@ 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, diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 26457e8e..7baaeb0e 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -238,8 +238,20 @@ passed. These checks exercise field guards, explicit/human/source requirements, bounds, correction variants and scoped support identities. They do not establish saved-message provenance or replayed domain validity. The inherited `card.corrected` acceptance of `add-excerpt`, despite its narrower protocol union, remains unchanged. -Research/state validators are still pending rather than silently omitted. - -Next bounded pure-state sub-slice: research-offer and snapshot guards in separate -modules, preserving the remaining archived validator functions through the façade. -Event replay/domain and durable processing follow those guards. +The remaining research/state validators are extracted in the following slice. + +## Research-offer and snapshot validation slice + +`research-validation.ts` and `state-validation.ts` complete the original validation +façade. All 17 archived functions and six constants match the source syntax trees; +no policy or runtime behavior changed. Independent review approved the slice: +12 focused tests passed, 0 failed (81 assertions). The combined guard/source/quote +run passed 30 tests (242 assertions); full workspace types passed on Bun 1.4.2. + +Research tests preserve exact briefs, named option snapshots and code-owned focus. +Snapshot tests cover queue identities, bounds, analysis distributions and offer links. +Inherited gaps remain: thread contents and event-ID uniqueness are unchecked; choice +membership uses inherited properties. Outcome references use analysis IDs rather +than proving accepted replay authority. These guards do not establish consent, +saved-message provenance or replay correctness. Formatting and CI retain the same +existing warnings. Next: bounded event replay modules, then the domain and persistence. From a3012b01f046bd59e6027d83a3dea126ab61b5db Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:10:15 +0100 Subject: [PATCH 007/152] Extract accepted-event replay in bounded modules --- .../conversation-plan/event-contributions.ts | 119 ++++++++ .../conversation-plan/event-corrections.ts | 110 +++++++ .../src/conversation-plan/event-lifecycle.ts | 129 +++++++++ .../src/conversation-plan/event-settlement.ts | 160 +++++++++++ .../conversation-plan/event-support.test.ts | 159 +++++++++++ .../src/conversation-plan/event-support.ts | 69 +++++ .../src/conversation-plan/events.test.ts | 268 ++++++++++++++++++ apps/server/src/conversation-plan/events.ts | 88 ++++++ .../src/conversation-plan/preference.ts | 94 ++++++ docs/jev-product-extraction.md | 24 +- 10 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/conversation-plan/event-contributions.ts create mode 100644 apps/server/src/conversation-plan/event-corrections.ts create mode 100644 apps/server/src/conversation-plan/event-lifecycle.ts create mode 100644 apps/server/src/conversation-plan/event-settlement.ts create mode 100644 apps/server/src/conversation-plan/event-support.test.ts create mode 100644 apps/server/src/conversation-plan/event-support.ts create mode 100644 apps/server/src/conversation-plan/events.test.ts create mode 100644 apps/server/src/conversation-plan/events.ts create mode 100644 apps/server/src/conversation-plan/preference.ts 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..01d927bb --- /dev/null +++ b/apps/server/src/conversation-plan/event-contributions.ts @@ -0,0 +1,119 @@ +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 >= 64) { + 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..b4743c89 --- /dev/null +++ b/apps/server/src/conversation-plan/event-corrections.ts @@ -0,0 +1,110 @@ +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 >= 64) { + 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/events.test.ts b/apps/server/src/conversation-plan/events.test.ts new file mode 100644 index 00000000..41e7b3cb --- /dev/null +++ b/apps/server/src/conversation-plan/events.test.ts @@ -0,0 +1,268 @@ +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); +}); diff --git a/apps/server/src/conversation-plan/events.ts b/apps/server/src/conversation-plan/events.ts new file mode 100644 index 00000000..cce2d3d1 --- /dev/null +++ b/apps/server/src/conversation-plan/events.ts @@ -0,0 +1,88 @@ +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; + +/** 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); + if (state.events.length >= MAX_EVENTS) throw new Error("conversation event limit reached"); + 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 next = structuredClone(state); + let thread = next.threads.find((item) => item.id === event.threadId)!; + 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 7baaeb0e..e6a7cdf0 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -1,7 +1,8 @@ # Jev product extraction — handoff This is a finite extraction from a preserved prototype, not a verified release or -classifier-quality fix. The transport, stored-identity, source-provenance and quote foundations are implemented in bounded slices. +classifier-quality fix. The transport, stable identity, provenance, validation and replay foundations are +implemented in bounded slices. - Product branch: `maggie/jev-chat-product`. - Product worktree: `/Users/maggieappleton/.codex/worktrees/jev-chat-product/chopin`. @@ -254,4 +255,23 @@ Inherited gaps remain: thread contents and event-ID uniqueness are unchecked; ch membership uses inherited properties. Outcome references use analysis IDs rather than proving accepted replay authority. These guards do not establish consent, saved-message provenance or replay correctness. Formatting and CI retain the same -existing warnings. Next: bounded event replay modules, then the domain and persistence. +existing warnings. + +## Accepted-event replay slice + +The dispatcher retains the archived opening, clone, deduplication and counter order. +Four bounded modules contain all 21 original case bodies; support helpers and +`preference.ts` preserve the archived behavior. Root syntax-tree comparison verifies +those cases, helpers, preamble and counters; independent review checks the routing. + +Nine focused tests passed, 0 failed (51 assertions), covering immutable inputs, +stale versions, contributions, decisions, deferral/resumption, correction target +counters, support withdrawals and exact Save lineage. Full workspace types, +formatting and CI passed on Bun 1.4.2 with existing warnings. Original domain tests +remain assigned to the next domain slice. Replay is still dormant: callers must +validate saved evidence and persist returned state before publication. + +Inherited behavior remains: duplicate IDs return before validating changed bodies; +opening shares unchanged prior objects; legacy candidate confirmation retains pending +proposals. A scoped Save remains provisional with its evidence retained, as intended. +Next: pure domain restoration, analysis completion and human correction transitions. From a70dde9a3c19ddadb7b0d1abfe4d1843ff94653b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:13:18 +0100 Subject: [PATCH 008/152] Extract pure conversation domain with original regressions --- .../conversation-plan/domain-analysis.test.ts | 82 +++++++ .../src/conversation-plan/domain-analysis.ts | 97 ++++++++ .../domain-corrections.test.ts | 182 +++++++++++++++ .../conversation-plan/domain-corrections.ts | 135 +++++++++++ .../src/conversation-plan/domain-initial.ts | 15 ++ .../src/conversation-plan/domain-research.ts | 88 ++++++++ .../conversation-plan/domain.test-fixtures.ts | 60 +++++ .../src/conversation-plan/domain.test.ts | 213 ++++++++++++++++++ apps/server/src/conversation-plan/domain.ts | 6 + .../conversation-plan/research-snapshots.ts | 91 ++++++++ .../src/conversation-plan/state-provenance.ts | 53 +++++ .../conversation-plan/state-restoration.ts | 72 ++++++ docs/jev-product-extraction.md | 160 +++++-------- 13 files changed, 1148 insertions(+), 106 deletions(-) create mode 100644 apps/server/src/conversation-plan/domain-analysis.test.ts create mode 100644 apps/server/src/conversation-plan/domain-analysis.ts create mode 100644 apps/server/src/conversation-plan/domain-corrections.test.ts create mode 100644 apps/server/src/conversation-plan/domain-corrections.ts create mode 100644 apps/server/src/conversation-plan/domain-initial.ts create mode 100644 apps/server/src/conversation-plan/domain-research.ts create mode 100644 apps/server/src/conversation-plan/domain.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/domain.test.ts create mode 100644 apps/server/src/conversation-plan/domain.ts create mode 100644 apps/server/src/conversation-plan/research-snapshots.ts create mode 100644 apps/server/src/conversation-plan/state-provenance.ts create mode 100644 apps/server/src/conversation-plan/state-restoration.ts 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/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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index e6a7cdf0..c391e184 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -163,115 +163,63 @@ port-8787 handoff, app restart or other-worktree mutation is part of this milest The old automatic goal remains paused. Continued work below belongs to this product extraction chat; it does not resume the old prototype goal. -## Stored decision identity slice - -Extract `storedText()` and `identified()` unchanged from the preserved schema and -export the validator publicly. It validates durable IDs, order and raw text lengths -without re-keying, trimming, freezing or mutating a definition. Current `normalize()`, -shared drafts and answer derivation retain their existing behavior. - -The two archived validator tests are retained with the pending-card validator portion. -Its draft-mode and unanswered-derive assertions depend on the later draft slice and -remain explicitly pending; changing draft behavior here would exceed this boundary. -Five additional tests cover stable IDs/order/identity, duplicate IDs across questions, -unknown fields, counts, and every raw text field. Test-first verification failed on the -missing public export, then passed after extraction. Independent review approved the -slice: 33 question-package tests passed, 0 failed (99 assertions), on Bun 1.4.2. -Full workspace types passed; formatting and CI checks passed with existing warnings. -The validator has no runtime consumers yet; durable restoration integration is pending. +## Reviewed foundation slices + +Each slice has a separate implementer and independent reviewer. Missing-module or +missing-export checks failed before extraction, then passed. The table records the +independent focused results on Bun 1.4.2; every slice also passed full workspace +and E2E type checks, formatting and CI with the existing warnings listed above. + +| Slice | Preserved behavior and evidence | Focused result | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | + +The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay +checks passed 27 tests (109 assertions). The complete extracted foundation and +question-package run passed 100 tests, 0 failed (493 assertions, 20.92 seconds). Replay tests +cover immutable inputs, stale versions, contributions, decisions, deferral/resumption, +correction target counters, support withdrawals and exact Save lineage. These modules +remain dormant. Saved evidence, authenticated callers and persistence before +publication belong to the integration slices. Domain tests preserve saved provenance, +replay restoration, correction retries, card authority and atomic analysis completion. + +Inherited behavior stays visible: + +- Stored empty single-choice definitions are valid. Main still starts empty drafts + in `custom`; the archived `choices` default and unanswered assertions are deferred + to the draft slice. +- Source guards retain uncapped handles and unusual enumerable-property shape quirks; + ordinary JSON arrays fail. Shape checks do not prove saved provenance. +- `card.corrected` accepts `add-excerpt` despite its narrower protocol union. +- Snapshot guards do not check thread contents or event-ID uniqueness. Choice + membership uses inherited properties; outcome IDs reference analysis records rather + than proving accepted replay. Research shape checks do not authorise consent. +- Duplicate event IDs return before checking changed bodies. Opening shares unchanged + prior objects; legacy candidate confirmation retains pending proposals. +- Scoped Save stays provisional with its evidence retained, as intended. +- Restore checks saved text only when a transcript is supplied. Research action retries + compare identity while ignoring changed timestamps; transitions grant no execution authority. ## Product tests with eval dependencies -Seven archived product tests currently read eval development fixtures or helpers: +Seven archived product tests read eval development fixtures or helpers: `d01-provenance-acceptance.test.ts`, `d01-scoped-endorsement.test.ts`, `d02-deferral-domain.test.ts`, `d02-deferral-effects.test.ts`, `d02-team-deferral.test.ts`, `d19-flow.test.ts`, and `quotes.test.ts` under -`apps/server/src/conversation-plan/`. Keep their assertions with the product slices. -Extract only the test messages, injected model replies and narrow state observations -needed by those assertions into product test fixtures/helpers with source provenance. -Do not import the eval runner/scorer/dataset tree or skip these tests. Original material -stays archived; reserved examples stay sealed. This adaptation is required before -claiming those slices' product regression coverage passes independently of eval tooling. - -## Conversation contract and source provenance slice - -Extract the complete archived `ConversationPlan` namespace and `sources.ts` unchanged. -The public namespace type export preserves all Save, research, correction and event -cases. Global Incoming/Outgoing unions, session flags and runtime routes are unchanged; -wire activation waits for authorized producers/handlers/consumers together. - -Six focused source tests cover exact UTF-16 spans (including emoji), completed saved -member/agent messages, source identity, text and author matching, streaming rejection, -unknown fields, missing fields, bounds and role allowlist. Test-first verification -failed on the missing module before extraction. Existing handle-length and enumerable -property shape quirks remain inherited; no classifier or validation behavior was tuned. -Independent review approved the slice: six source tests passed, 0 failed (53 -assertions), and the protocol type check passed on Bun 1.4.2. The implementer also -verified the 39 combined source/question tests (152 assertions). Full workspace types, -formatting and CI passed with the same existing warnings. These checks establish -standalone provenance validation, not live conversation integration. - -## Exact quote extraction slice - -The parser and quote budget are byte-identical to the archive. All four original -quote test bodies and assertions remain; five exact development messages now live in -`quotes.test-fixtures.ts` with archive SHA, case, step and source path. No labels, -scoring or dataset imports accompany them. Root and reviewer checked text parity, -including apostrophes and UTF-16 offsets. A fifth regression verifies that four -candidates succeed and a fifth throws rather than silently losing source evidence. - -Test-first verification failed on the missing parser module before extraction. -Independent review approved the slice: 5 tests passed, 0 failed (20 assertions), on -Bun 1.4.2. Full workspace types, formatting and CI passed with existing warnings. -This resolves `quotes.test.ts`'s eval fixture dependency; the other six listed product -test adaptations remain pending. No parsing rules or classifier policy changed. - -## Event and correction validation slice - -The archived guard functions now live in `event-validation.ts`, -`correction-validation.ts` and the leaf `validation-fields.ts`; `validation.ts` -reexports their original public APIs and five bounds. Dependencies are acyclic. -Independent review and root syntax-tree comparison verified all 11 function bodies -and five constants against the archive; every event/correction case remains. - -Independent Bun 1.4.2 tests: 7 passed, 0 failed (88 assertions). Full workspace types -passed. These checks exercise field guards, explicit/human/source requirements, -bounds, correction variants and scoped support identities. They do not establish -saved-message provenance or replayed domain validity. The inherited `card.corrected` -acceptance of `add-excerpt`, despite its narrower protocol union, remains unchanged. -The remaining research/state validators are extracted in the following slice. - -## Research-offer and snapshot validation slice - -`research-validation.ts` and `state-validation.ts` complete the original validation -façade. All 17 archived functions and six constants match the source syntax trees; -no policy or runtime behavior changed. Independent review approved the slice: -12 focused tests passed, 0 failed (81 assertions). The combined guard/source/quote -run passed 30 tests (242 assertions); full workspace types passed on Bun 1.4.2. - -Research tests preserve exact briefs, named option snapshots and code-owned focus. -Snapshot tests cover queue identities, bounds, analysis distributions and offer links. -Inherited gaps remain: thread contents and event-ID uniqueness are unchecked; choice -membership uses inherited properties. Outcome references use analysis IDs rather -than proving accepted replay authority. These guards do not establish consent, -saved-message provenance or replay correctness. Formatting and CI retain the same -existing warnings. - -## Accepted-event replay slice - -The dispatcher retains the archived opening, clone, deduplication and counter order. -Four bounded modules contain all 21 original case bodies; support helpers and -`preference.ts` preserve the archived behavior. Root syntax-tree comparison verifies -those cases, helpers, preamble and counters; independent review checks the routing. - -Nine focused tests passed, 0 failed (51 assertions), covering immutable inputs, -stale versions, contributions, decisions, deferral/resumption, correction target -counters, support withdrawals and exact Save lineage. Full workspace types, -formatting and CI passed on Bun 1.4.2 with existing warnings. Original domain tests -remain assigned to the next domain slice. Replay is still dormant: callers must -validate saved evidence and persist returned state before publication. - -Inherited behavior remains: duplicate IDs return before validating changed bodies; -opening shares unchanged prior objects; legacy candidate confirmation retains pending -proposals. A scoped Save remains provisional with its evidence retained, as intended. -Next: pure domain restoration, analysis completion and human correction transitions. +`apps/server/src/conversation-plan/`. Keep every assertion with its product slice. +Extract only the required messages, injected replies and narrow state observations +into product test fixtures/helpers with source provenance. Do not import runner, +scorer or dataset trees, or skip these tests. Reserved examples stay sealed. + +The quote dependency is resolved: `quotes.test-fixtures.ts` contains exact D19/m1,m2, +D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root and +reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test +adaptations remain pending. + +Next: original research-offer domain regressions, then stable card definitions and +records. Durable processing, Planner jobs and browser integration remain unimplemented. From 964433fe9ebba608da0a56b73c5a5177c13300a4 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:15:31 +0100 Subject: [PATCH 009/152] Preserve research-offer domain regressions --- .../research-offers-comparison.test.ts | 178 +++++++++++++++++ .../research-offers-concern.test.ts | 161 +++++++++++++++ .../research-offers.test-fixtures.ts | 185 ++++++++++++++++++ .../conversation-plan/research-offers.test.ts | 139 +++++++++++++ docs/jev-product-extraction.md | 2 +- 5 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/research-offers-comparison.test.ts create mode 100644 apps/server/src/conversation-plan/research-offers-concern.test.ts create mode 100644 apps/server/src/conversation-plan/research-offers.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/research-offers.test.ts 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index c391e184..2e42c1c8 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -221,5 +221,5 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: original research-offer domain regressions, then stable card definitions and +Next: the remaining pure card/Save regressions, then stable card definitions and records. Durable processing, Planner jobs and browser integration remain unimplemented. From 90852cd1661a79ccfd3cb3744fd03085f2bfae2c Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:17:22 +0100 Subject: [PATCH 010/152] Preserve card authority and scoped Save regressions --- .../cards-domain-agreement.test.ts | 215 ++++++++++++++++++ .../cards-domain-restore.test.ts | 180 +++++++++++++++ .../cards-domain.test-fixtures.ts | 80 +++++++ .../conversation-plan/cards-domain.test.ts | 172 ++++++++++++++ .../scoped-save-validation.test.ts | 185 +++++++++++++++ docs/jev-product-extraction.md | 24 +- 6 files changed, 846 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/conversation-plan/cards-domain-agreement.test.ts create mode 100644 apps/server/src/conversation-plan/cards-domain-restore.test.ts create mode 100644 apps/server/src/conversation-plan/cards-domain.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/cards-domain.test.ts create mode 100644 apps/server/src/conversation-plan/scoped-save-validation.test.ts 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 2e42c1c8..6619151b 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,14 +170,17 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -221,5 +224,6 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: the remaining pure card/Save regressions, then stable card definitions and -records. Durable processing, Planner jobs and browser integration remain unimplemented. +Next: stable card option growth and answer identifiers, then durable records. The +remaining archived correction/review suites follow their dependencies. Durable +processing, Planner jobs and browser integration remain unimplemented. From 929af957a9af036750db0dc30bc3ec6f84557c98 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:19:14 +0100 Subject: [PATCH 011/152] Extract stable decision option growth --- docs/jev-product-extraction.md | 27 +++++----- packages/question/src/index.ts | 3 ++ packages/question/src/limits.ts | 2 + packages/question/src/options.test.ts | 78 +++++++++++++++++++++++++++ packages/question/src/options.ts | 57 ++++++++++++++++++++ 5 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 packages/question/src/options.test.ts create mode 100644 packages/question/src/options.ts diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 6619151b..068fc9de 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,17 +170,18 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -193,6 +194,8 @@ replay restoration, correction retries, card authority and atomic analysis compl Inherited behavior stays visible: +- Option growth assumes a valid single-question definition/model and a caller-supplied + unique durable ID. Its label limit and 10-option cap do not validate IDs. - Stored empty single-choice definitions are valid. Main still starts empty drafts in `custom`; the archived `choices` default and unanswered assertions are deferred to the draft slice. @@ -224,6 +227,6 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: stable card option growth and answer identifiers, then durable records. The +Next: answer identifiers and durable record validation. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. diff --git a/packages/question/src/index.ts b/packages/question/src/index.ts index 02a5c5f7..3ef37b8a 100644 --- a/packages/question/src/index.ts +++ b/packages/question/src/index.ts @@ -21,6 +21,9 @@ export type { Applied, Draft, Drafts, Mode, Model } from "./draft"; export { derive, incomplete, summarize } from "./answer"; export type { Outcome } from "./answer"; +export { addOption } from "./options"; +export type { Added } from "./options"; + /** * The CRDT itself. * diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index 7d622d0c..6bccfa23 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -7,6 +7,8 @@ export const MAX_QUESTIONS = 10; export const MAX_OPTIONS = 20; +/** Options a person may grow a decision card to. */ +export const MAX_DECISION_OPTIONS = 10; export const MAX_HEADER = 80; export const MAX_QUESTION = 1_000; export const MAX_LABEL = 200; diff --git a/packages/question/src/options.test.ts b/packages/question/src/options.test.ts new file mode 100644 index 00000000..b42ea121 --- /dev/null +++ b/packages/question/src/options.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; + +import { create, read } from "./draft"; +import * as limits from "./limits"; +import { addOption } from "./options"; +import { decision, normalize } from "./schema"; + +function start(count = 2) { + let definition = decision(normalize({ + questions: [{ + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: Array.from({ length: count }, (_, index) => ({ + label: `Option ${index}`, + description: "", + })), + }], + })); + return { definition, model: create(definition) }; +} + +describe("addOption", () => { + it("appends an option to the definition and an unselected register to the draft", () => { + let { definition, model } = start(); + let result = addOption(definition, model, "o9", " GitHub Apps "); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.option).toEqual({ id: "o9", label: "GitHub Apps", description: "" }); + expect(result.definition.questions[0]!.options.map(option => option.id)).toEqual([ + "o0", + "o1", + "o9", + ]); + let drafts = read(result.model, result.definition); + expect(drafts.q0!.options).toEqual({ o0: false, o1: false, o9: false }); + }); + + it("keeps an existing selection", () => { + let { definition, model } = start(); + model.api.val(["q0", "choice"]).set("o1"); + let result = addOption(definition, model, "o9", "Roll our own"); + expect(result.ok && read(result.model, result.definition).q0!.choice).toBe("o1"); + }); + + it("does not mutate its inputs", () => { + let { definition, model } = start(); + let before = [...model.toBinary()]; + addOption(definition, model, "o9", "Roll our own"); + expect([...model.toBinary()]).toEqual(before); + expect(definition.questions[0]!.options).toHaveLength(2); + }); + + it("refuses a duplicate label, ignoring case and surrounding space", () => { + let { definition, model } = start(); + let result = addOption(definition, model, "o9", " option 1 "); + expect(result).toMatchObject({ ok: false, reason: "duplicate" }); + }); + + it("refuses an empty or over-long label", () => { + let { definition, model } = start(); + expect(addOption(definition, model, "o9", " ")).toMatchObject({ + ok: false, + reason: "invalid", + }); + expect(addOption(definition, model, "o9", "x".repeat(limits.MAX_LABEL + 1))) + .toMatchObject({ ok: false, reason: "invalid" }); + }); + + it("refuses once the card holds the decision-card maximum", () => { + let { definition, model } = start(limits.MAX_DECISION_OPTIONS); + expect(addOption(definition, model, "o99", "One more")).toMatchObject({ + ok: false, + reason: "full", + }); + }); +}); diff --git a/packages/question/src/options.ts b/packages/question/src/options.ts new file mode 100644 index 00000000..65f39cda --- /dev/null +++ b/packages/question/src/options.ts @@ -0,0 +1,57 @@ +/** + * Growing a decision. + * + * The one way a definition changes after it is asked: people add options. + * The definition and the shared draft change together, because the draft's + * shape is derived from the definition and `read` rejects any mismatch. + */ + +import { crdt, read } from "./draft"; +import * as limits from "./limits"; +import { decision } from "./schema"; + +import type { Model } from "./draft"; +import type { DecisionDefinition, Option } from "./schema"; + +export type Added = + | { ok: true; definition: DecisionDefinition; model: Model; option: Option } + | { ok: false; reason: "full" | "duplicate" | "invalid"; message: string }; + +export function addOption( + definition: DecisionDefinition, + model: Model, + id: string, + label: string, +): Added { + let question = definition.questions[0]!; + let text = label.trim(); + if (!text) return { ok: false, reason: "invalid", message: "An option needs a label" }; + if (text.length > limits.MAX_LABEL) { + return { + ok: false, + reason: "invalid", + message: `An option label is at most ${limits.MAX_LABEL} characters`, + }; + } + if (question.options.length >= limits.MAX_DECISION_OPTIONS) { + return { + ok: false, + reason: "full", + message: `A decision holds at most ${limits.MAX_DECISION_OPTIONS} options`, + }; + } + let folded = text.toLocaleLowerCase(); + if (question.options.some(option => option.label.trim().toLocaleLowerCase() === folded)) { + return { ok: false, reason: "duplicate", message: "That is already an option" }; + } + + let option: Option = { id, label: text, description: "" }; + let next = decision({ questions: [{ ...question, options: [...question.options, option] }] }); + + let copy = model.clone(); + copy.api.obj([question.id, "options"]).set({ [id]: crdt.schema.val(crdt.schema.con(false)) }); + copy.api.flush(); + read(copy, next); + + return { ok: true, definition: next, model: copy, option }; +} From 85397c6d5d55be21e0c44728be8482c5672b7167 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:21:15 +0100 Subject: [PATCH 012/152] Preserve selected option identifiers in answers --- apps/server/src/questions.service.test.ts | 12 +++++++-- docs/jev-product-extraction.md | 31 +++++++++++++---------- packages/protocol/question.d.ts | 6 ++--- packages/question/src/answer.ts | 5 ++-- packages/question/src/question.test.ts | 5 ++-- 5 files changed, 35 insertions(+), 24 deletions(-) diff --git a/apps/server/src/questions.service.test.ts b/apps/server/src/questions.service.test.ts index 69560b4d..bb74aaf2 100644 --- a/apps/server/src/questions.service.test.ts +++ b/apps/server/src/questions.service.test.ts @@ -119,12 +119,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], + }], }, ]); }); diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 068fc9de..6d81020d 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,18 +170,19 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -189,7 +190,9 @@ question-package run passed 100 tests, 0 failed (493 assertions, 20.92 seconds). cover immutable inputs, stale versions, contributions, decisions, deferral/resumption, correction target counters, support withdrawals and exact Save lineage. These modules remain dormant. Saved evidence, authenticated callers and persistence before -publication belong to the integration slices. Domain tests preserve saved provenance, +publication belong to the integration slices. Current question submission returns the +new IDs but still persists/projects summarised strings; durable ID-backed records +remain pending. Domain tests preserve saved provenance, replay restoration, correction retries, card authority and atomic analysis completion. Inherited behavior stays visible: @@ -227,6 +230,6 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: answer identifiers and durable record validation. The +Next: durable record validation. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index 753d6a3d..c978e024 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -72,13 +72,13 @@ export declare namespace Question { /** * A decided answer. * - * Carries the question text and the chosen labels rather than identifiers, - * so it still reads as prose to an agent, and still means something in a - * transcript after the definition it came from is gone. + * Carries readable labels for the agent and option ids for durable decisions. */ export type Answer = { question: string; choices?: string[]; + /** Ids of the chosen options, alongside their labels. */ + optionIds?: string[]; custom?: string; }; diff --git a/packages/question/src/answer.ts b/packages/question/src/answer.ts index 799c64b5..20a2c12a 100644 --- a/packages/question/src/answer.ts +++ b/packages/question/src/answer.ts @@ -18,9 +18,7 @@ export type Outcome = /** * Derive answers from a draft. * - * Answers carry the question text and chosen labels rather than identifiers: - * the agent reads them as prose, and they stay meaningful in a transcript long - * after the definition is gone. + * Answers carry readable labels for the agent and identifiers for the record. */ export function derive(definition: Definition, drafts: Drafts): Outcome { let answers: Answer[] = []; @@ -58,6 +56,7 @@ export function derive(definition: Definition, drafts: Drafts): Outcome { answers.push({ question: question.question, choices: selected.map(option => option.label), + optionIds: selected.map(option => option.id), }); } diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index 8e5078cc..67cdc817 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -318,7 +318,7 @@ describe("draft", () => { describe("derive", () => { let definition = normalize(tool()); - it("returns the chosen labels, not identifiers", () => { + it("returns the chosen labels and option identifiers", () => { let outcome = derive(definition, { q0: { mode: "choices", choice: "o0", options: {}, custom: "" }, }); @@ -326,7 +326,7 @@ describe("derive", () => { expect(outcome.ok).toBe(true); if (!outcome.ok) return; expect(outcome.answers).toEqual([ - { question: "How should we deploy?", choices: ["Canary"] }, + { question: "How should we deploy?", choices: ["Canary"], optionIds: ["o0"] }, ]); }); @@ -360,6 +360,7 @@ describe("derive", () => { expect(outcome.ok).toBe(true); if (!outcome.ok) return; expect(outcome.answers[0]!.choices).toEqual(["Canary", "Blue-green"]); + expect(outcome.answers[0]!.optionIds).toEqual(["o0", "o1"]); }); it("reports the first unanswered question", () => { From c1f93462ee3241d06534f96578cb8646bd4df650 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:25:04 +0100 Subject: [PATCH 013/152] Extract decision record validation and ID bounds --- apps/server/src/questions/record-fields.ts | 31 +++ .../server/src/questions/record-provenance.ts | 45 +++++ apps/server/src/questions/record-types.ts | 37 ++++ .../server/src/questions/record-validation.ts | 162 ++++++++++++++++ .../src/questions/records.bounds.test.ts | 24 +++ .../src/questions/records.reopen.test.ts | 182 ++++++++++++++++++ apps/server/src/questions/records.ts | 68 +++++++ docs/jev-product-extraction.md | 6 +- packages/dialect/src/limits.ts | 3 + 9 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/questions/record-fields.ts create mode 100644 apps/server/src/questions/record-provenance.ts create mode 100644 apps/server/src/questions/record-types.ts create mode 100644 apps/server/src/questions/record-validation.ts create mode 100644 apps/server/src/questions/records.bounds.test.ts create mode 100644 apps/server/src/questions/records.reopen.test.ts create mode 100644 apps/server/src/questions/records.ts 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..b8c9ec0f --- /dev/null +++ b/apps/server/src/questions/record-types.ts @@ -0,0 +1,37 @@ +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[]; +}; 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..8bf2cd0e --- /dev/null +++ b/apps/server/src/questions/records.bounds.test.ts @@ -0,0 +1,24 @@ +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"); +}); 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..40fe55c2 --- /dev/null +++ b/apps/server/src/questions/records.ts @@ -0,0 +1,68 @@ +/** 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); + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 6d81020d..b468f05a 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -183,6 +183,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | | Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | | Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -199,6 +200,9 @@ Inherited behavior stays visible: - Option growth assumes a valid single-question definition/model and a caller-supplied unique durable ID. Its label limit and 10-option cap do not validate IDs. +- Record normalisation preserves unknown top-level fields and shallow nested references. + Definition, anchors, current-answer completeness and some string bounds remain + partial checks; source shape/ref matching does not prove saved Chat provenance. - Stored empty single-choice definitions are valid. Main still starts empty drafts in `custom`; the archived `choices` default and unanswered assertions are deferred to the draft slice. @@ -230,6 +234,6 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: durable record validation. The +Next: pending-card draft defaults, then durable record/store integration. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index 0fbf44ea..ab30a8e9 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -20,6 +20,9 @@ export const MAX_COLLAB_BYTES = 4 * 1024 * 1024; export const MAX_TABLE_ROWS = 100; export const MAX_TABLE_COLUMNS = 20; +/** Matches the conversation thread ID bound in conversation-plan validation. */ +export const MAX_ID = 200; + /** Image nodes per plan. Each is a remote fetch when the plan renders. */ export const MAX_IMAGES = 100; From a3d9b61610ba6ad61ad9ed3558f27a158607ee5a Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:28:26 +0100 Subject: [PATCH 014/152] Preserve pending-card choice draft behavior --- docs/jev-product-extraction.md | 8 +++---- packages/question/src/draft.ts | 4 +--- packages/question/src/options.test.ts | 30 +++++++++++++++++++++++++- packages/question/src/question.test.ts | 7 ++++++ 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index b468f05a..7ca2d282 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -184,6 +184,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | | Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | | Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -203,9 +204,8 @@ Inherited behavior stays visible: - Record normalisation preserves unknown top-level fields and shallow nested references. Definition, anchors, current-answer completeness and some string bounds remain partial checks; source shape/ref matching does not prove saved Chat provenance. -- Stored empty single-choice definitions are valid. Main still starts empty drafts - in `custom`; the archived `choices` default and unanswered assertions are deferred - to the draft slice. +- Stored empty single-choice definitions are valid. Newly created pending drafts + now start in `choices`; stored `custom` modes/text are read and restored unchanged. - Source guards retain uncapped handles and unusual enumerable-property shape quirks; ordinary JSON arrays fail. Shape checks do not prove saved provenance. - `card.corrected` accepts `add-excerpt` despite its narrower protocol union. @@ -234,6 +234,6 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: pending-card draft defaults, then durable record/store integration. The +Next: shared definition restoration and durable record/store integration. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index 3d26556c..e869d9b5 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -39,9 +39,7 @@ export function create(definition: Definition): Model { } questions[question.id] = crdt.schema.obj({ - mode: crdt.schema.val( - crdt.schema.con(question.options.length ? "choices" : "custom"), - ), + mode: crdt.schema.val(crdt.schema.con("choices")), choice: crdt.schema.val(crdt.schema.con(null)), options: crdt.schema.obj(options), // A CRDT string, so two people typing a custom answer merge rather diff --git a/packages/question/src/options.test.ts b/packages/question/src/options.test.ts index b42ea121..da0556cf 100644 --- a/packages/question/src/options.test.ts +++ b/packages/question/src/options.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import { create, read } from "./draft"; +import { derive } from "./answer"; import * as limits from "./limits"; import { addOption } from "./options"; -import { decision, normalize } from "./schema"; +import { decision, identified, normalize } from "./schema"; function start(count = 2) { let definition = decision(normalize({ @@ -76,3 +77,30 @@ describe("addOption", () => { }); }); }); + +it("a pending card grows its first option and derives its selected label and ID", () => { + let definition = decision(identified({ + questions: [{ + id: "pending", + header: "Auth", + question: "Which system?", + multiple: false, + options: [], + }], + })); + let model = create(definition); + let added = addOption(definition, model, "stable-option", "GitHub Apps"); + expect(added.ok).toBe(true); + if (!added.ok) return; + expect(read(added.model, added.definition).pending!.mode).toBe("choices"); + added.model.api.val(["pending", "choice"]).set("stable-option"); + added.model.api.flush(); + expect(derive(added.definition, read(added.model, added.definition))).toEqual({ + ok: true, + answers: [{ + question: "Which system?", + choices: ["GitHub Apps"], + optionIds: ["stable-option"], + }], + }); +}); diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index 67cdc817..3e432376 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -101,6 +101,13 @@ describe("identified", () => { }], }; expect(identified(pending)).toBe(pending); + expect(read(create(pending), pending).q1).toEqual({ + mode: "choices", + choice: null, + options: {}, + custom: "", + }); + expect(derive(pending, read(create(pending), pending)).ok).toBe(false); expect(() => normalize(tool({ options: [] }))).toThrow(/at least one option/); expect(() => identified({ From 121fa9e1e4f6f2b11725292ec21c02123031c45c Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:33:47 +0100 Subject: [PATCH 015/152] Restore shared definitions without replacing durable IDs --- .../src/questions/store-restore.test.ts | 81 ++++++++++++++ apps/server/src/questions/store.ts | 12 +- docs/jev-product-extraction.md | 40 ++++--- packages/protocol/question.d.ts | 6 +- .../use-questionnaire-definition.test.ts | 103 ++++++++++++++++++ .../src/react/use-questionnaire.test.ts | 2 +- .../question/src/react/use-questionnaire.ts | 10 +- 7 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/questions/store-restore.test.ts create mode 100644 packages/question/src/react/use-questionnaire-definition.test.ts 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..ed10a51a --- /dev/null +++ b/apps/server/src/questions/store-restore.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Store from "./store"; + +// legacy() and the original restoration callback: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/questions/store.test.ts. Remaining archive Store callbacks are deferred. +function legacy() { + return { + questions: ["q1", "q2"].map(id => ({ + id, + header: id, + question: `${id}?`, + multiple: false, + options: [{ id: `${id}-a`, label: "One", description: "" }], + })), + }; +} + +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.ts b/apps/server/src/questions/store.ts index 33cb8cea..7807223a 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -16,7 +16,7 @@ import * as Question from "@chopin/question"; -import type { Answer, DecisionDefinition, Definition, Drafts, Model } from "@chopin/question"; +import type { Answer, Definition, Drafts, Model } from "@chopin/question"; /** How long a resolved questionnaire is remembered, for late arrivals. */ const CLOSED_TTL = 5 * 60 * 1_000; @@ -34,7 +34,7 @@ export type Ended = type Open = { id: string; - definition: DecisionDefinition; + definition: Definition; /** The plan node this belongs to, when it has one. */ widget?: string; model: Model; @@ -68,7 +68,7 @@ export type Questions = { export type StoredOpen = { id: string; - definition: DecisionDefinition; + definition: Definition; widget?: string; model: number[]; revision: number; @@ -99,7 +99,7 @@ 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); questions.open.set(entry.id, { id: entry.id, definition, @@ -166,7 +166,7 @@ export function get(questions: Questions, id: string): Open | undefined { /** 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, @@ -177,7 +177,7 @@ export function outstanding( export type Opened = | { open: true; - definition: DecisionDefinition; + definition: Definition; model: number[]; revision: number; presence: Collaborator[]; diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 7ca2d282..a5302c2f 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,21 +170,22 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -206,6 +207,9 @@ Inherited behavior stays visible: partial checks; source shape/ref matching does not prove saved Chat provenance. - Stored empty single-choice definitions are valid. Newly created pending drafts now start in `choices`; stored `custom` modes/text are read and restored unchanged. +- Tool normalisation assigns option IDs per question; stored multi-question definitions + must have globally unique IDs. The old duplicate-ID controller fixture remains + rejected; valid legacy fixtures preserve their saved IDs. - Source guards retain uncapped handles and unusual enumerable-property shape quirks; ordinary JSON arrays fail. Shape checks do not prove saved provenance. - `card.corrected` accepts `add-excerpt` despite its narrower protocol union. @@ -234,6 +238,10 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Next: shared definition restoration and durable record/store integration. The +One of 19 archived Store callbacks is now retained in `store-restore.test.ts`; the +remaining 18 follow option, reopen, editor and suggestion operations. Server Store +and controller restoration are tested independently of full sidecar/socket recovery. + +Next: Store option/lifecycle operations, then durable record integration. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index c978e024..6c2a8b14 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -101,14 +101,14 @@ export declare namespace Question { /** A new questionnaire, announced to the room. */ export type Asked = KIND<"question:asked"> & { id: string; - definition: DecisionDefinition; + definition: Definition; /** Present once the questionnaire has a node in the plan. */ widget?: string; }; /** Every open questionnaire, sent when a client joins. */ export type Sync = KIND<"question:sync"> & { - open: Array<{ id: string; definition: DecisionDefinition; widget?: string }>; + open: Array<{ id: string; definition: Definition; widget?: string }>; }; export namespace Open { @@ -119,7 +119,7 @@ export declare namespace Question { & ( | { open: true; - definition: DecisionDefinition; + definition: Definition; /** json-joy model, as bytes. */ model: number[]; revision: number; diff --git a/packages/question/src/react/use-questionnaire-definition.test.ts b/packages/question/src/react/use-questionnaire-definition.test.ts new file mode 100644 index 00000000..918fc447 --- /dev/null +++ b/packages/question/src/react/use-questionnaire-definition.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; + +import { create } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; + +import type { Definition } from "../index"; +import type { Transport } from "./use-questionnaire"; + +function legacy(): Definition { + return { + questions: ["saved-first", "saved-second"].map(id => ({ + id, + header: id, + question: `${id}?`, + multiple: false, + options: [{ id: `${id}-option`, label: "One", description: "" }], + })), + }; +} + +function bridge(definition: unknown, modelDefinition: Definition) { + let opens = 0; + let submissions = 0; + let model = create(modelDefinition); + let value = { + async ask(kind: string) { + if (kind === "question:open") { + opens++; + return { open: true, definition, model: [...model.toBinary()], revision: 4, presence: [] }; + } + if (kind === "question:submit") submissions++; + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on() { + return () => {}; + }, + } as unknown as Transport; + return { value, opens: () => opens, submissions: () => submissions }; +} + +describe("QuestionnaireController shared definitions", () => { + it("opens a legacy multi-question definition with its durable question and option IDs", async () => { + let definition = legacy(); + let channel = bridge(definition, definition); + let controller = new QuestionnaireController(channel.value, "saved-card", undefined, true); + let off = controller.subscribe(() => {}); + try { + await new Promise(resolve => setTimeout(resolve, 0)); + let snapshot = controller.getSnapshot(); + expect(snapshot.error).toBeUndefined(); + expect(snapshot.syncing).toBe(false); + expect(snapshot.definition).toEqual(definition); + expect(Object.keys(snapshot.drafts)).toEqual(["saved-first", "saved-second"]); + expect(snapshot.drafts["saved-second"]?.options).toEqual({ "saved-second-option": false }); + expect(channel.opens()).toBe(1); + expect(channel.submissions()).toBe(0); + } finally { + off(); + } + }); + + it("opens a pending empty single-choice card without changing its durable ID or mode", async () => { + let definition: Definition = { + questions: [{ + id: "pending-question", + header: "Choice", + question: "Which option?", + multiple: false, + options: [], + }], + }; + let channel = bridge(definition, definition); + let controller = new QuestionnaireController(channel.value, "pending-card", undefined, true); + let off = controller.subscribe(() => {}); + try { + await new Promise(resolve => setTimeout(resolve, 0)); + expect(controller.getSnapshot().error).toBeUndefined(); + expect(controller.getSnapshot().definition).toEqual(definition); + expect(controller.getSnapshot().drafts).toEqual({ + "pending-question": { mode: "choices", choice: null, custom: "", options: {} }, + }); + } finally { + off(); + } + }); + + it("rejects an invalid identified open payload before accepting the shared definition", async () => { + let definition = { questions: [legacy().questions[0]!] }; + let channel = bridge({ ...definition, extra: true }, definition); + let controller = new QuestionnaireController(channel.value, "invalid-card", undefined, true); + let off = controller.subscribe(() => {}); + try { + await new Promise(resolve => setTimeout(resolve, 0)); + expect(controller.getSnapshot().error).toBe("Unable to sync shared answers."); + expect(controller.getSnapshot().definition).toBeUndefined(); + expect(controller.getSnapshot().drafts).toEqual({}); + expect(channel.submissions()).toBe(0); + } finally { + off(); + } + }); +}); diff --git a/packages/question/src/react/use-questionnaire.test.ts b/packages/question/src/react/use-questionnaire.test.ts index dbeb4c0d..6852082d 100644 --- a/packages/question/src/react/use-questionnaire.test.ts +++ b/packages/question/src/react/use-questionnaire.test.ts @@ -91,7 +91,7 @@ function transport(definition = DEFINITION) { } describe("QuestionnaireController", () => { - it("rejects a questionnaire returned for an independent decision record", async () => { + it("rejects duplicate durable option IDs across questionnaire questions", async () => { let bridge = transport(QUESTIONNAIRE); let controller = new QuestionnaireController( bridge.value, diff --git a/packages/question/src/react/use-questionnaire.ts b/packages/question/src/react/use-questionnaire.ts index adda5072..552691ff 100644 --- a/packages/question/src/react/use-questionnaire.ts +++ b/packages/question/src/react/use-questionnaire.ts @@ -14,9 +14,9 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { derive } from "../answer"; import { crdt } from "../draft"; -import { decision } from "../schema"; +import { identified } from "../schema"; -import type { DecisionDefinition, Definition, Drafts } from "../index"; +import type { Definition, Drafts } from "../index"; import type { Collaborator } from "./question-view"; type Unsubscribe = () => void; @@ -73,7 +73,7 @@ export class QuestionnaireController { #listeners = new Set<() => void>(); #teardown: Unsubscribe[] = []; #model: Model | undefined; - #definition: DecisionDefinition | undefined; + #definition: Definition | undefined; #revision = 0; #outbox: number[][] = []; #pending = Promise.resolve(); @@ -340,9 +340,9 @@ export class QuestionnaireController { if (!this.#valid(generation)) return; if (!reply.open) return this.#close(); - let definition: DecisionDefinition; + let definition: Definition; try { - definition = decision(reply.definition!); + definition = identified(reply.definition!); } catch { this.#set({ syncing: false, error: "Unable to sync shared answers." }); return; From 626918f123ebc4c688caecdb528c4bfb4334f060 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:39:48 +0100 Subject: [PATCH 016/152] Extract Store option and lifecycle operations --- apps/server/src/questions/store-edit.test.ts | 38 ++++ apps/server/src/questions/store-lifecycle.ts | 27 +++ .../src/questions/store-options.test.ts | 78 ++++++++ apps/server/src/questions/store-options.ts | 167 ++++++++++++++++++ .../server/src/questions/store-reopen.test.ts | 30 ++++ .../src/questions/store-restore.test.ts | 17 +- .../src/questions/store-retitle.test.ts | 58 ++++++ apps/server/src/questions/store-types.ts | 59 +++++++ .../src/questions/store.test-fixtures.ts | 32 ++++ apps/server/src/questions/store.ts | 85 ++++----- docs/jev-product-extraction.md | 16 +- 11 files changed, 534 insertions(+), 73 deletions(-) create mode 100644 apps/server/src/questions/store-edit.test.ts create mode 100644 apps/server/src/questions/store-lifecycle.ts create mode 100644 apps/server/src/questions/store-options.test.ts create mode 100644 apps/server/src/questions/store-options.ts create mode 100644 apps/server/src/questions/store-reopen.test.ts create mode 100644 apps/server/src/questions/store-retitle.test.ts create mode 100644 apps/server/src/questions/store-types.ts create mode 100644 apps/server/src/questions/store.test-fixtures.ts 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..9d7308e1 --- /dev/null +++ b/apps/server/src/questions/store-options.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. +// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. +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..4dbf6d84 --- /dev/null +++ b/apps/server/src/questions/store-reopen.test.ts @@ -0,0 +1,30 @@ +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. +// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. +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 index ed10a51a..ac90e2be 100644 --- a/apps/server/src/questions/store-restore.test.ts +++ b/apps/server/src/questions/store-restore.test.ts @@ -3,19 +3,10 @@ import * as Question from "@chopin/question"; import * as Store from "./store"; -// legacy() and the original restoration callback: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, -// apps/server/src/questions/store.test.ts. Remaining archive Store callbacks are deferred. -function legacy() { - return { - questions: ["q1", "q2"].map(id => ({ - id, - header: id, - question: `${id}?`, - multiple: false, - options: [{ id: `${id}-a`, label: "One", description: "" }], - })), - }; -} +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", () => { 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..e5895f61 --- /dev/null +++ b/apps/server/src/questions/store-retitle.test.ts @@ -0,0 +1,58 @@ +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. +// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. +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("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-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 7807223a..67cf4ee0 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -16,64 +16,35 @@ import * as Question from "@chopin/question"; -import type { Answer, Definition, Drafts, Model } from "@chopin/question"; +import type { Answer, 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 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: Definition; - /** 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: Definition; - widget?: string; - model: number[]; - revision: number; -}; - export function create(): Questions { return { open: new Map(), closed: new Map() }; } @@ -107,6 +78,7 @@ export function restore(entries: StoredOpen[]): Questions { model: Question.restore(entry.model, definition), revision: entry.revision, presence: new Map(), + editors: new Set(), }); } return questions; @@ -154,6 +126,7 @@ export function ask( model, revision: 0, presence: new Map(), + editors: new Set(), settle, }); }); @@ -208,7 +181,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); @@ -234,6 +207,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 }; } diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index a5302c2f..bef766ee 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -186,13 +186,15 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | | Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | | Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and question-package run passed 100 tests, 0 failed (493 assertions, 20.92 seconds). Replay tests cover immutable inputs, stale versions, contributions, decisions, deferral/resumption, -correction target counters, support withdrawals and exact Save lineage. These modules -remain dormant. Saved evidence, authenticated callers and persistence before +correction target counters, support withdrawals and exact Save lineage. Conversation +processing remains dormant; existing question paths use the extracted answer, draft +and definition validation behavior. Saved evidence, authenticated callers and persistence before publication belong to the integration slices. Current question submission returns the new IDs but still persists/projects summarised strings; durable ID-backed records remain pending. Domain tests preserve saved provenance, @@ -238,10 +240,14 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -One of 19 archived Store callbacks is now retained in `store-restore.test.ts`; the -remaining 18 follow option, reopen, editor and suggestion operations. Server Store +Nine of 19 archived Store callbacks are retained across the bounded Store suites; +the remaining ten cover advisory retitling and suggestions. New Store mutation APIs +have no service/routes/UI callers yet. Suggestion fields are typed groundwork; +dump/restore and the suggestion producer remain pending. Capture/revert retains +references, reservations require caller sequencing, and relabel bounds remain partial. +The existing stage-before-commit durability gap is unchanged. Server Store and controller restoration are tested independently of full sidecar/socket recovery. -Next: Store option/lifecycle operations, then durable record integration. The +Next: Store suggestions and their persistence, then durable record integration. The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. From 169c52bd91d99a31e1cfe4402cab1042bdfe197f Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:44:57 +0100 Subject: [PATCH 017/152] Preserve Store suggestion metadata and explicit submit fallback --- .../src/questions/store-options.test.ts | 1 - .../server/src/questions/store-reopen.test.ts | 1 - .../src/questions/store-retitle.test.ts | 22 +++- .../questions/store-suggestion-queue.test.ts | 89 +++++++++++++ .../questions/store-suggestion-state.test.ts | 120 ++++++++++++++++++ .../questions/store-suggestion-submit.test.ts | 58 +++++++++ .../server/src/questions/store-suggestions.ts | 50 ++++++++ apps/server/src/questions/store.ts | 39 +++++- docs/jev-product-extraction.md | 24 ++-- 9 files changed, 389 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/questions/store-suggestion-queue.test.ts create mode 100644 apps/server/src/questions/store-suggestion-state.test.ts create mode 100644 apps/server/src/questions/store-suggestion-submit.test.ts create mode 100644 apps/server/src/questions/store-suggestions.ts diff --git a/apps/server/src/questions/store-options.test.ts b/apps/server/src/questions/store-options.test.ts index 9d7308e1..49ec8c42 100644 --- a/apps/server/src/questions/store-options.test.ts +++ b/apps/server/src/questions/store-options.test.ts @@ -5,7 +5,6 @@ import * as Store from "./store"; import { asked } from "./store.test-fixtures"; // Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. -// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. describe("Store.addOption", () => { it("grows the definition and shared draft together", () => { let questions = asked(); diff --git a/apps/server/src/questions/store-reopen.test.ts b/apps/server/src/questions/store-reopen.test.ts index 4dbf6d84..92b28b0f 100644 --- a/apps/server/src/questions/store-reopen.test.ts +++ b/apps/server/src/questions/store-reopen.test.ts @@ -5,7 +5,6 @@ import * as Store from "./store"; import { asked, legacy } from "./store.test-fixtures"; // Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. -// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. describe("Store.reopen", () => { it("reuses identity with a fresh draft, no Planner waiter, and no old tombstone", () => { let questions = asked(); diff --git a/apps/server/src/questions/store-retitle.test.ts b/apps/server/src/questions/store-retitle.test.ts index e5895f61..58071850 100644 --- a/apps/server/src/questions/store-retitle.test.ts +++ b/apps/server/src/questions/store-retitle.test.ts @@ -5,7 +5,6 @@ import * as Store from "./store"; import { asked } from "./store.test-fixtures"; // Original callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. -// Advisory retitle/editor/suggest callbacks are deferred to the next bounded slice. describe("Store.retitle", () => { it("changes one decision question without changing the human draft or option identities", () => { let questions = asked(); @@ -33,6 +32,27 @@ describe("Store.retitle", () => { 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({ 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.ts b/apps/server/src/questions/store.ts index 67cf4ee0..4acee4cf 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -16,6 +16,8 @@ import * as Question from "@chopin/question"; +import { untouched } from "./store-suggestions"; + import type { Answer, Definition, Drafts } from "@chopin/question"; import type { Claim, @@ -40,6 +42,7 @@ export { 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. */ @@ -56,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 } : {}), })); } @@ -71,12 +75,33 @@ export function restore(entries: StoredOpen[]): Questions { || entry.model.some(value => !Number.isInteger(value) || value < 0 || value > 255) ) Question.reject("Questionnaire model is invalid"); 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(), }); @@ -279,6 +304,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); @@ -301,6 +327,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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index bef766ee..f690dabc 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -187,6 +187,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | | Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | | Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 19 original Store callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -240,14 +241,15 @@ D02/m2, D03/m1 and D04/m5 messages with archive SHA, case, step and path. Root a reviewer verified text parity, apostrophes and UTF-16 offsets. The other six test adaptations remain pending. -Nine of 19 archived Store callbacks are retained across the bounded Store suites; -the remaining ten cover advisory retitling and suggestions. New Store mutation APIs -have no service/routes/UI callers yet. Suggestion fields are typed groundwork; -dump/restore and the suggestion producer remain pending. Capture/revert retains -references, reservations require caller sequencing, and relabel bounds remain partial. -The existing stage-before-commit durability gap is unchanged. Server Store -and controller restoration are tested independently of full sidecar/socket recovery. - -Next: Store suggestions and their persistence, then durable record integration. The -remaining archived correction/review suites follow their dependencies. Durable -processing, Planner jobs and browser integration remain unimplemented. +All 19 archived Store callbacks are retained across the bounded Store suites. +New Store mutation and suggestion APIs have no service/routes/UI callers yet. +Suggestions persist as metadata; their evidence IDs pass shape checks rather than +transcript provenance checks. Explicit submit fallback derives a copied draft and +leaves the shared model unchanged. Capture/revert retains references, reservations +require caller sequencing, and relabel bounds remain partial. The existing +stage-before-commit durability gap is unchanged. Server Store and controller +restoration are tested independently of full sidecar/socket recovery. + +Next: durable record integration. The remaining archived correction/review suites +follow their dependencies. Durable processing, Planner jobs and browser integration +remain unimplemented. From 21750d02e8d00006c89cb25f1d1dc0b924c9bfe6 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:51:15 +0100 Subject: [PATCH 018/152] Restore canonical question records with saved draft and source checks --- .../plan/question-record-persistence.test.ts | 176 ++++++++++++++++++ .../src/plan/question-record-sources.test.ts | 82 ++++++++ .../src/plan/question-record.test-fixtures.ts | 55 ++++++ apps/server/src/plan/service.ts | 58 ++++-- apps/server/src/questions.anchors.test.ts | 2 +- apps/server/src/questions/anchors.test.ts | 4 + apps/server/src/questions/service.ts | 33 ++-- docs/jev-product-extraction.md | 51 ++--- 8 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/plan/question-record-persistence.test.ts create mode 100644 apps/server/src/plan/question-record-sources.test.ts create mode 100644 apps/server/src/plan/question-record.test-fixtures.ts diff --git a/apps/server/src/plan/question-record-persistence.test.ts b/apps/server/src/plan/question-record-persistence.test.ts new file mode 100644 index 00000000..b83b464b --- /dev/null +++ b/apps/server/src/plan/question-record-persistence.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "bun:test"; +import * as Question from "@chopin/question"; +import * as Service from "./service"; +import * as Store from "../questions/store"; +import { definition, draft, legacyRecord, rejected, stored } from "./question-record.test-fixtures"; + +// Restoration contract extracted from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/plan/service.ts; these tests exercise current MemoryStorage open/close. +describe("durable question record restoration", () => { + 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..894736aa --- /dev/null +++ b/apps/server/src/plan/question-record-sources.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +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, record } = sourced(); + let context = await stored([record], [draft()], [message]); + let plan = context.plan; + try { + 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/service.ts b/apps/server/src/plan/service.ts index a6a448a7..c2105e4d 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -14,6 +14,9 @@ import * as Y from "yjs"; import { createHash } from "node:crypto"; import { MENTION } from "@chopin/protocol/address"; +import * as Question from "@chopin/question"; + +import { validateSource } from "../conversation-plan/sources"; import * as presence from "./presence"; import * as edit from "./edit"; @@ -344,6 +347,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, @@ -416,18 +429,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 +460,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 ( @@ -482,7 +514,7 @@ 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"], @@ -944,7 +976,9 @@ export async function open( comments: Comments.create(), chat: Chat.restore(sidecar.transcript), 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, 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/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/service.ts b/apps/server/src/questions/service.ts index 9b5e9ed9..42205637 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -31,6 +31,15 @@ import * as Service from "../plan/service"; import type { Plan } from "../plan/service"; import type { Socket, SocketData } from "../wire"; import type { Ended, Questions } from "./store"; +import type { Record } from "./records"; + +export { + isOpenStatus, + matchesQuestionSource, + normalizeRecord, + questionMentionsOption, +} from "./records"; +export type { DecisionEntry, OptionOrigin, Record } from "./records"; export type { Questions } from "./store"; @@ -63,20 +72,6 @@ export function identify(raw: unknown): Definition { }; } -/** 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; -}; - function decide( entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, definition: Definition, @@ -131,7 +126,15 @@ export async function ask( })), }], }; - let record: Record = { id, definition: single, status: "open" }; + let record: Record = { + id, + definition: single, + status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }; if (anchors) { record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); } diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index f690dabc..bc70f9a3 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,24 +170,25 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 19 original Store callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -247,9 +248,15 @@ Suggestions persist as metadata; their evidence IDs pass shape checks rather tha transcript provenance checks. Explicit submit fallback derives a copied draft and leaves the shared model unchanged. Capture/revert retains references, reservations require caller sequencing, and relabel bounds remain partial. The existing -stage-before-commit durability gap is unchanged. Server Store and controller -restoration are tested independently of full sidecar/socket recovery. +stage-before-commit durability gap is unchanged. Server Store suggestions and canonical records now round-trip through the memory +sidecar open/close path. Controller restoration remains independently tested; +PostgreSQL and socket recovery have not been run. Synthetic anchor fixtures gained +required canonical fields and one valid saved option; every original assertion stays. +New Planner asks initialize canonical collections, while existing text-answer +submission and history/choice mutations remain unchanged. Saved option sources are +checked against completed transcript text and authors, without claiming thread/event +authority. -Next: durable record integration. The remaining archived correction/review suites -follow their dependencies. Durable processing, Planner jobs and browser integration +Next: bounded interpretation request builders, followed by policy and processing. +The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration remain unimplemented. From ed2b3a1cea6cca97b5b3ce397276518183995d84 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:55:20 +0100 Subject: [PATCH 019/152] Extract exact interpretation request builders into bounded modules --- .../question-builder.test-fixtures.ts | 75 ++++ .../question-clarification.ts | 51 +++ .../src/conversation-plan/question-context.ts | 134 ++++++++ .../conversation-plan/question-research.ts | 122 +++++++ .../src/conversation-plan/question-shared.ts | 32 ++ .../conversation-plan/question-targeting.ts | 324 ++++++++++++++++++ .../src/conversation-plan/question-triage.ts | 210 ++++++++++++ .../questions-duplicates.test.ts | 163 +++++++++ .../questions-targeting.test.ts | 67 ++++ .../src/conversation-plan/questions.test.ts | 63 ++++ .../server/src/conversation-plan/questions.ts | 29 ++ docs/jev-product-extraction.md | 17 +- 12 files changed, 1282 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/conversation-plan/question-builder.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/question-clarification.ts create mode 100644 apps/server/src/conversation-plan/question-context.ts create mode 100644 apps/server/src/conversation-plan/question-research.ts create mode 100644 apps/server/src/conversation-plan/question-shared.ts create mode 100644 apps/server/src/conversation-plan/question-targeting.ts create mode 100644 apps/server/src/conversation-plan/question-triage.ts create mode 100644 apps/server/src/conversation-plan/questions-duplicates.test.ts create mode 100644 apps/server/src/conversation-plan/questions-targeting.test.ts create mode 100644 apps/server/src/conversation-plan/questions.test.ts create mode 100644 apps/server/src/conversation-plan/questions.ts 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-clarification.ts b/apps/server/src/conversation-plan/question-clarification.ts new file mode 100644 index 00000000..d814e6e8 --- /dev/null +++ b/apps/server/src/conversation-plan/question-clarification.ts @@ -0,0 +1,51 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import { isBareEditorOptionList, type QuoteCandidate } from "./quotes"; +import { noul } from "./question-shared"; + +/** One joint judgment over four already extracted, exact source spans. */ +export function buildBareEditorClarificationRequest( + message: Chat.Entry, + thread: ConversationPlan.Thread, + candidates: readonly QuoteCandidate[], +): JevRequest { + if ( + message.author.kind !== "member" || !isBareEditorOptionList(message.text, candidates) + || !["exploring", "leaning", "reopened"].includes(thread.status) + || thread.contributions.some(item => item.kind === "option") + ) throw new Error("invalid bare editor clarification context"); + let questions: Record = { + list_kind: { + type: "choice", + instructions: + "Judge the four exact spans in spans[] together. Are they four distinct, directly proposed editor options owned by current.author for the one thread, rather than a quote, report, negation, duplicate, or one compound option? Do not invent or rewrite any span.", + criteria: { + four_distinct_options: + "All four spans are separate sincere options for the one editor decision.", + other: "The spans are not four distinct speaker-owned options.", + unclear: "The list's ownership, target, or four-way structure is uncertain.", + }, + }, + }; + for (let index = 0; index < 4; index++) { + questions[`c${index}_distinct_option`] = noul( + `Does spans[${index}].quote itself name one distinct option for thread.question, separate from the other three exact spans? Require this speaker's own current proposal; reported, negated, duplicate, or uncertain alternatives are false.`, + "A distinct directly proposed editor option in this exact span.", + "Not a distinct directly proposed option in this span.", + ); + } + return { + state: { + current: { id: message.id, author: message.author, text: message.text }, + thread: { + id: thread.id, + question: thread.question, + status: thread.status, + version: thread.version, + options: [], + }, + spans: candidates.map((candidate, index) => ({ index, ...candidate })), + }, + questions, + }; +} 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..dff5f0a7 --- /dev/null +++ b/apps/server/src/conversation-plan/question-context.ts @@ -0,0 +1,134 @@ +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[]): VisibleThread[] { + let ordered = [ + ...threads.filter((thread) => thread.status !== "discarded"), + ...threads.filter((thread) => thread.status === "discarded"), + ].slice(0, 12); + 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 card = linkedCards.get(thread.id); + let visibleOptions = options.map((item) => ({ id: item.id, text: 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: thread.contributions.find((item) => item.id === pending.optionId) + ?.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-research.ts b/apps/server/src/conversation-plan/question-research.ts new file mode 100644 index 00000000..8f4c8cb6 --- /dev/null +++ b/apps/server/src/conversation-plan/question-research.ts @@ -0,0 +1,122 @@ +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[], +): JevRequest { + assertQuoteBudget(candidates); + let selected = visibleThreads(threads).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).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..1da413fa --- /dev/null +++ b/apps/server/src/conversation-plan/question-shared.ts @@ -0,0 +1,32 @@ +import type { JevQuestion } from "./jev"; + +export const QUESTION_SET_VERSION = "conversation-plan-8"; +export const BARE_EDITOR_CLARIFICATION_VERSION = "bare-editor-clarification-1"; + +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..cfae4844 --- /dev/null +++ b/apps/server/src/conversation-plan/question-targeting.ts @@ -0,0 +1,324 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import { isBareEditorOptionList, 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 bareEditor = isBareEditorOptionList(message.text, 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); + // 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 && !bareEditor) { + 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; + if (!bareEditor) return true; + return /_((?:role|thread|option|new_option|duplicate))$/.test(key); + }), + ), + }; +} 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..b55b1247 --- /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); + 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..c184c30d --- /dev/null +++ b/apps/server/src/conversation-plan/questions.ts @@ -0,0 +1,29 @@ +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 { buildBareEditorClarificationRequest } from "./question-clarification"; +export { buildResearchOfferRequest } from "./question-research"; +export { + BARE_EDITOR_CLARIFICATION_VERSION, + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index bc70f9a3..b769d59e 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -189,6 +189,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | | Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | | Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -248,8 +249,8 @@ Suggestions persist as metadata; their evidence IDs pass shape checks rather tha transcript provenance checks. Explicit submit fallback derives a copied draft and leaves the shared model unchanged. Capture/revert retains references, reservations require caller sequencing, and relabel bounds remain partial. The existing -stage-before-commit durability gap is unchanged. Server Store suggestions and canonical records now round-trip through the memory -sidecar open/close path. Controller restoration remains independently tested; +stage-before-commit durability gap is unchanged. Server Store suggestions and +canonical records now round-trip through the memory sidecar open/close path. Controller restoration remains independently tested; PostgreSQL and socket recovery have not been run. Synthetic anchor fixtures gained required canonical fields and one valid saved option; every original assertion stays. New Planner asks initialize canonical collections, while existing text-answer @@ -257,6 +258,12 @@ submission and history/choice mutations remain unchanged. Saved option sources a checked against completed transcript text and authors, without claiming thread/event authority. -Next: bounded interpretation request builders, followed by policy and processing. -The remaining archived correction/review suites follow their dependencies. Durable processing, Planner jobs and browser integration -remain unimplemented. +Request builders retain the original 12-thread, 32-option and 23,500-character +context bounds and reserved IDs. Duplicate comparisons depend on the caller's saved +message prefix, excluding later, source-free, streaming and system evidence. +No request builder calls the model. The final parameterized `questions.test.ts` +interpreter callback (two cases) remains owned by policy/interpreter integration; +it has not been replaced with a skipped test. + +Next: remaining pure correction/review regressions, then exact policy/interpreter +and durable processing. Planner jobs and browser integration remain unimplemented. From ca64bd17de9a18e336530566125bc564e4ad9232 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:58:00 +0100 Subject: [PATCH 020/152] Retain exact correction and review regression scenarios --- .../corrections-contributions.test.ts | 112 +++++++++++ .../corrections-excerpts.test.ts | 108 +++++++++++ .../corrections.test-fixtures.ts | 128 +++++++++++++ .../src/conversation-plan/corrections.test.ts | 161 ++++++++++++++++ .../conversation-plan/review-analysis.test.ts | 64 +++++++ .../conversation-plan/review.test-fixtures.ts | 100 ++++++++++ .../src/conversation-plan/review.test.ts | 176 ++++++++++++++++++ docs/jev-product-extraction.md | 7 +- 8 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/conversation-plan/corrections-contributions.test.ts create mode 100644 apps/server/src/conversation-plan/corrections-excerpts.test.ts create mode 100644 apps/server/src/conversation-plan/corrections.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/corrections.test.ts create mode 100644 apps/server/src/conversation-plan/review-analysis.test.ts create mode 100644 apps/server/src/conversation-plan/review.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/review.test.ts 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index b769d59e..5a878052 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -190,6 +190,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | | Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | | Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -250,7 +251,8 @@ transcript provenance checks. Explicit submit fallback derives a copied draft an leaves the shared model unchanged. Capture/revert retains references, reservations require caller sequencing, and relabel bounds remain partial. The existing stage-before-commit durability gap is unchanged. Server Store suggestions and -canonical records now round-trip through the memory sidecar open/close path. Controller restoration remains independently tested; +canonical records now round-trip through the memory sidecar open/close path. +Controller restoration remains independently tested; PostgreSQL and socket recovery have not been run. Synthetic anchor fixtures gained required canonical fields and one valid saved option; every original assertion stays. New Planner asks initialize canonical collections, while existing text-answer @@ -265,5 +267,4 @@ No request builder calls the model. The final parameterized `questions.test.ts` interpreter callback (two cases) remains owned by policy/interpreter integration; it has not been replaced with a skipped test. -Next: remaining pure correction/review regressions, then exact policy/interpreter -and durable processing. Planner jobs and browser integration remain unimplemented. +Next: exact policy/interpreter extraction and durable processing. Planner jobs and browser integration remain unimplemented. From c6c4fc1f56cc05c59aae3ef26b654224894a4761 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:04:26 +0100 Subject: [PATCH 021/152] Preserve policy scoring identity and clarification helpers --- .../conversation-plan/policy-clarification.ts | 58 +++++++++++++++++ .../src/conversation-plan/policy-cues.ts | 45 ++++++++++++++ .../conversation-plan/policy-identity.test.ts | 19 ++++++ .../src/conversation-plan/policy-identity.ts | 47 ++++++++++++++ .../src/conversation-plan/policy-scoring.ts | 62 +++++++++++++++++++ .../src/conversation-plan/policy-types.ts | 24 +++++++ docs/jev-product-extraction.md | 54 +++++++++------- 7 files changed, 287 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-clarification.ts create mode 100644 apps/server/src/conversation-plan/policy-cues.ts create mode 100644 apps/server/src/conversation-plan/policy-identity.test.ts create mode 100644 apps/server/src/conversation-plan/policy-identity.ts create mode 100644 apps/server/src/conversation-plan/policy-scoring.ts create mode 100644 apps/server/src/conversation-plan/policy-types.ts diff --git a/apps/server/src/conversation-plan/policy-clarification.ts b/apps/server/src/conversation-plan/policy-clarification.ts new file mode 100644 index 00000000..c5b30e50 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-clarification.ts @@ -0,0 +1,58 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import type { PolicyInput } from "./policy-types"; +import { isBareEditorOptionList } from "./quotes"; +import { choice, moderateChoice, noul, roleChoice } from "./policy-scoring"; + +export function bareEditorCardEmpty(input: PolicyInput, thread: ConversationPlan.Thread): boolean { + let card = input.linkedCards?.get(thread.id); + if (!thread.questionnaireId) return !card; + return card?.cardId === thread.questionnaireId && card.options.length === 0; +} + +/** Only this exact four-span, empty-thread case may ask for a joint second judgment. */ +export function bareEditorClarificationThread( + input: PolicyInput, +): ConversationPlan.Thread | undefined { + let { message, first } = input; + if (message.author.kind !== "member" || !isBareEditorOptionList(message.text, input.candidates)) { + return; + } + let open = input.state.threads.filter(item => !["decided", "discarded"].includes(item.status)); + let thread = open.length === 1 ? open[0] : undefined; + if ( + !thread || !/\beditor\b/i.test(thread.question) + || thread.contributions.some(item => item.kind === "option") + || !bareEditorCardEmpty(input, thread) + || choice(first, "thread_target") !== thread.id + || noul(first, "new_option") < 0.9 + ) return; + let labels = input.candidates.map(item => item.quote.replace(/^bare\s+/i, "").toLowerCase()); + if (new Set(labels).size !== 4) return; + if ( + !input.candidates.every((candidate, index) => + message.text.slice(candidate.start, candidate.end) === candidate.quote + && noul(first, `c${index}_owned_unretracted`) >= 0.8 + && roleChoice(candidate.answers, first) === "option" + && moderateChoice(candidate.answers, "role", "option", 0.55) + && choice(candidate.answers, "thread") === thread.id + && moderateChoice(candidate.answers, "option", "new", 0.7) + && noul(candidate.answers, "new_option") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.5 + ) + ) return; + return thread; +} + +export function strongBareEditorClarification( + answers: Record | undefined, +): boolean { + if (!answers) return false; + let kind = answers.list_kind; + if (kind?.type !== "choice" || kind.choice !== "four_distinct_options") return false; + let selected = kind.probabilities.four_distinct_options ?? 0; + let other = Math.max(kind.probabilities.other ?? 0, kind.probabilities.unclear ?? 0); + return kind.confidence >= 0.8 && selected >= 0.9 && selected - other >= 0.3 + && Array.from({ length: 4 }, (_, index) => noul(answers, `c${index}_distinct_option`) >= 0.9) + .every(Boolean); +} 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-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-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-types.ts b/apps/server/src/conversation-plan/policy-types.ts new file mode 100644 index 00000000..0f1b726f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-types.ts @@ -0,0 +1,24 @@ +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[]; + clarification?: Record; +}; +export type PolicyResult = { + events: Event[]; + selectedTarget?: string; + candidates: Array<{ id: string; kind: "resolution" | "reopening"; targetId?: string }>; + outcomes: ConversationPlan.CandidateOutcome[]; + policyGate: string; +}; diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 5a878052..4d36e646 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,27 +170,28 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | -| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | -| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | -| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -267,4 +268,13 @@ No request builder calls the model. The final parameterized `questions.test.ts` interpreter callback (two cases) remains owned by policy/interpreter integration; it has not been replaced with a skipped test. -Next: exact policy/interpreter extraction and durable processing. Planner jobs and browser integration remain unimplemented. +Policy scoring thresholds, cue patterns and identity encoding remain unchanged. +The original D19 `policy.test.ts` callback and remaining `pipeline.test.ts` callbacks +stay assigned to later slices. The mixed pipeline source is 6,941 lines; its size +does not reduce coverage ownership. Policy dispatch, interpreter, durable processing, +Planner jobs and browser integration remain unimplemented. + +Next: extract the policy's ordered terminal stages, then candidate dispatch. Preserve +shared candidate state, local capture timing, continuation and event-limit checks, +and partial events retained after follow-up failure. Keep the original public +interface and compare original offline fixtures before publishing the dispatcher. From 9de5b06d453cad9a14c96db66bcc5c70f3be8890 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:19:38 +0100 Subject: [PATCH 022/152] Extract ordered initial policy terminal stages with exact regressions --- .../src/conversation-plan/policy-compound.ts | 128 ++++++++++++++ .../src/conversation-plan/policy-context.ts | 87 ++++++++++ .../policy-direct-recovery.test.ts | 144 ++++++++++++++++ .../policy-direct-recovery.ts | 125 ++++++++++++++ .../policy-initial-terminals.test.ts | 153 +++++++++++++++++ .../policy-initial-terminals.ts | 24 +++ .../policy-initial.test-fixtures.ts | 157 +++++++++++++++++ .../conversation-plan/policy-topic.test.ts | 161 ++++++++++++++++++ docs/jev-product-extraction.md | 64 ++++--- 9 files changed, 1017 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-compound.ts create mode 100644 apps/server/src/conversation-plan/policy-context.ts create mode 100644 apps/server/src/conversation-plan/policy-direct-recovery.test.ts create mode 100644 apps/server/src/conversation-plan/policy-direct-recovery.ts create mode 100644 apps/server/src/conversation-plan/policy-initial-terminals.test.ts create mode 100644 apps/server/src/conversation-plan/policy-initial-terminals.ts create mode 100644 apps/server/src/conversation-plan/policy-initial.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-topic.test.ts 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..bff8afe6 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-compound.ts @@ -0,0 +1,128 @@ +import { applyInference } from "./domain"; +import type { DirectFacts, PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import { choice, moderateChoice, noul } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { isAttributedCompoundDecision, isExplicitCompoundDecision } from "./quotes"; + +export function topicCorroboration( + context: PolicyContext, + facts: DirectFacts, +): PolicyResult | undefined { + let { input, message, first, events, reviews } = context; + let { topicThreeWay, exactDirectAlternatives, directBounds } = facts; + if ( + topicThreeWay && !(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: "topic alternatives need candidate corroboration", + eventIds: [], + })), + policyGate: "topic 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..0607f5e6 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-context.ts @@ -0,0 +1,87 @@ +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; + topicThreeWay: boolean; +}; +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; + 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 topicThreeWay = /\bwhat sends\b[^?]{1,160}\?\s+our\b[^,]{1,160},/i + .test(message.text); + + return { + optionLabels, + directAlternatives, + exactDirectAlternatives, + questionKey, + matchingThreads, + directBounds, + topicThreeWay, + }; +} 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-initial-terminals.test.ts b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts new file mode 100644 index 00000000..90416917 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts @@ -0,0 +1,153 @@ +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 { isAttributedCompoundDecision } from "./quotes"; +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 before reading message or initialization state", () => { + let input = inputFor(compound); + input.candidates = Array.from({ length: 5 }, () => input.candidates[0]!); + Object.defineProperty(input, "message", { + get() { + throw new Error("message read too early"); + }, + }); + Object.defineProperty(input, "state", { + get() { + throw new Error("state read too early"); + }, + }); + expect(() => createPolicyContext(input)).toThrow("source quote count exceeds 4"); +}); + +test.each(["streaming", "empty", "system"] as const)( + "ineligible %s returns before reading thread facts", + 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" }; + Object.defineProperty(input.state, "threads", { + get() { + throw new Error("facts read too early"); + }, + }); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toEqual({ + events: [], + candidates: [], + outcomes: [], + policyGate: "ineligible message", + }); + expect(context.facts).toBeUndefined(); + }, +); + +test("topic corroboration precedes compound attribution", () => { + let text = + "what sends notices? our relay, Alice said we need to choose how people sign in and how agents get credentials."; + let input = inputFor(text); + input.candidates = ["how people sign in", "how agents get credentials"].map(quote => { + let start = text.indexOf(quote); + return { quote, start, end: start + quote.length, answers: input.candidates[0]!.answers }; + }); + expect(isAttributedCompoundDecision(text, input.candidates)).toBe(true); + expect(terminalPolicy(input).policyGate).toBe("topic alternatives need candidate corroboration"); +}); + +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("facts retain the initialized message without rereading an input getter", () => { + let input = inputFor("Should audit logs go in PostgreSQL or object storage?"); + let original = input.message; + let reads = 0; + Object.defineProperty(input, "message", { + get() { + reads++; + return reads === 1 ? original : { ...original, text: "Changed after initialization." }; + }, + }); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(reads).toBe(1); + expect(context.facts?.questionKey).toBe(original.text.toLowerCase()); + expect(context.facts?.exactDirectAlternatives).toBe(true); +}); + +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..2113c2af --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.ts @@ -0,0 +1,24 @@ +import { compoundAttribution, compoundOpenings, topicCorroboration } 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 = topicCorroboration(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-topic.test.ts b/apps/server/src/conversation-plan/policy-topic.test.ts new file mode 100644 index 00000000..32c439ff --- /dev/null +++ b/apps/server/src/conversation-plan/policy-topic.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { directAlternativeQuotes, extractQuotes } from "./quotes"; +import { + confidentChoice, + first, + follow, + message, + terminalPolicy as planEvents, +} from "./policy-initial.test-fixtures"; + +// 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 4d36e646..89835564 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,28 +170,29 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | -| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | -| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | -| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | -| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | +| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -274,7 +275,18 @@ stay assigned to later slices. The mixed pipeline source is 6,941 lines; its siz does not reduce coverage ownership. Policy dispatch, interpreter, durable processing, Planner jobs and browser integration remain unimplemented. -Next: extract the policy's ordered terminal stages, then candidate dispatch. Preserve -shared candidate state, local capture timing, continuation and event-limit checks, -and partial events retained after follow-up failure. Keep the original public -interface and compare original offline fixtures before publishing the dispatcher. +The first four terminal stages preserve budget-before-initialization and +eligibility-before-facts ordering, topic/attribution/compound/recovery order, atomic +compound staging, and terminal recovery rejection. Captured messages and the original +quoted-option closure survive continuation. A wrapper capture mismatch was corrected +with a failing isolated negative control. Nineteen original statements remain exact; +the separated unset target initializer explicitly assigns `undefined` to satisfy +lint, with that one normalization permitted by the source proof. Policy gates stay exact. +Thirty-one terminal results match the preserved offline policy, with two explicit +continuation comparisons and input-mutation checks. These checks cover this prefix, +not the complete policy. Synthetic cap fixtures do not claim replay consistency. + +Next: remaining ordered terminal stages, then candidate dispatch. Preserve shared +candidate state, local capture timing, continuation and event-limit checks, and +partial events retained after follow-up failure. Keep the original public interface +and compare original offline fixtures before publishing the dispatcher. From 9e45bed47076c4bf7d0595f1d96c8a150efb4653 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:31:00 +0100 Subject: [PATCH 023/152] Extract remaining policy terminal stages with exact regression coverage --- .../policy-bare-editor.test.ts | 132 +++++++++++ .../conversation-plan/policy-bare-editor.ts | 83 +++++++ .../src/conversation-plan/policy-context.ts | 1 + .../policy-declarative.test.ts | 223 ++++++++++++++++++ .../conversation-plan/policy-declarative.ts | 108 +++++++++ .../policy-direct-alternatives.ts | 83 +++++++ .../policy-purpose-question.ts | 100 ++++++++ .../policy-remaining-terminals.test.ts | 210 +++++++++++++++++ .../policy-remaining-terminals.ts | 25 ++ .../policy-terminal.test-fixtures.ts | 200 ++++++++++++++++ docs/jev-product-extraction.md | 8 +- 11 files changed, 1172 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-bare-editor.test.ts create mode 100644 apps/server/src/conversation-plan/policy-bare-editor.ts create mode 100644 apps/server/src/conversation-plan/policy-declarative.test.ts create mode 100644 apps/server/src/conversation-plan/policy-declarative.ts create mode 100644 apps/server/src/conversation-plan/policy-direct-alternatives.ts create mode 100644 apps/server/src/conversation-plan/policy-purpose-question.ts create mode 100644 apps/server/src/conversation-plan/policy-remaining-terminals.test.ts create mode 100644 apps/server/src/conversation-plan/policy-remaining-terminals.ts create mode 100644 apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts diff --git a/apps/server/src/conversation-plan/policy-bare-editor.test.ts b/apps/server/src/conversation-plan/policy-bare-editor.test.ts new file mode 100644 index 00000000..82e464da --- /dev/null +++ b/apps/server/src/conversation-plan/policy-bare-editor.test.ts @@ -0,0 +1,132 @@ +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, terminalPolicy as planEvents } from "./policy-terminal.test-fixtures"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; + +// 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([]); + } +}); diff --git a/apps/server/src/conversation-plan/policy-bare-editor.ts b/apps/server/src/conversation-plan/policy-bare-editor.ts new file mode 100644 index 00000000..a22906a7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-bare-editor.ts @@ -0,0 +1,83 @@ +import { applyInference } from "./domain"; +import { + bareEditorCardEmpty, + bareEditorClarificationThread, + strongBareEditorClarification, +} from "./policy-clarification"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { isBareEditorOptionList } from "./quotes"; + +export function bareEditorList(context: PolicyContext): PolicyResult | undefined { + let { input, message, first, events, reviews, outcomes } = context; + let quotedOption = context.quotedOption!; + if (isBareEditorOptionList(message.text, input.candidates)) { + let open = input.state.threads.filter(thread => + !["decided", "discarded"].includes(thread.status) + ); + let thread = open.length === 1 ? open[0] : undefined; + let existing = new Set( + thread?.contributions.filter(item => item.kind === "option") + .map(item => item.text.trim().replace(/^bare\s+/i, "").toLowerCase()), + ); + let stronglyEligible = message.author.kind === "member" && !!thread + && /\beditor\b/i.test(thread.question) + && bareEditorCardEmpty(input, thread) + && 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 + && !existing.has(candidate.quote.replace(/^bare\s+/i, "").toLowerCase()) + ); + let clarified = bareEditorClarificationThread(input); + let eligible = stronglyEligible + || !!thread && clarified?.id === thread.id + && strongBareEditorClarification(input.clarification); + if (!eligible || !thread) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "bare editor list needs one clear current thread", + eventIds: [], + })), + policyGate: "bare editor list needs 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: "bare editor list 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: "bare editor list accepted", + }; + } +} diff --git a/apps/server/src/conversation-plan/policy-context.ts b/apps/server/src/conversation-plan/policy-context.ts index 0607f5e6..0d1c085c 100644 --- a/apps/server/src/conversation-plan/policy-context.ts +++ b/apps/server/src/conversation-plan/policy-context.ts @@ -24,6 +24,7 @@ export type PolicyContext = { working: State; selectedTarget: string | undefined; facts?: DirectFacts; + directQuestion?: boolean; quotedOption?: ( candidate: QuoteCandidate, index: number, 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-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..041578c5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts @@ -0,0 +1,210 @@ +import { expect, test } from "bun:test"; +import { inputFor } from "./policy-initial.test-fixtures"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { + capEvents, + d01RecordedOpening, + declarativeInput, + editorInput, + 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.each(["declarative", "editor"] as const)( + "%s batch rejection leaves staged state local", + kind => { + let input = kind === "declarative" ? declarativeInput() : editorInput(); + capEvents(input, MAX_EVENTS - 1); + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe( + kind === "declarative" ? "declarative options rejected" : "bare editor list rejected", + ); + expect(result.events).toEqual([]); + expect(context.working).toBe(input.state); + expect(input).toEqual(before); + }, +); + +test.each(["declarative", "editor"] as const)( + "%s quoted-option errors stay outside inference catches", + kind => { + let input = kind === "declarative" ? declarativeInput() : editorInput(); + 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.each(["strong", "weak", "missing"] as const)("editor joint clarification is %s", strength => { + let input = editorInput(); + for (let candidate of input.candidates) { + candidate.answers.role = { + type: "choice", + choice: "option", + confidence: 0.65, + probabilities: { option: 0.65, none: 0.35 }, + }; + candidate.answers.option = { + type: "choice", + choice: "new", + confidence: 0.75, + probabilities: { new: 0.75, none: 0.25 }, + }; + candidate.answers.new_option = { type: "noul", noul: 0.75 }; + } + if (strength !== "missing") { + input.clarification = { + list_kind: { + type: "choice", + choice: "four_distinct_options", + confidence: 0.95, + probabilities: { + four_distinct_options: strength === "strong" ? 0.95 : 0.7, + other: strength === "strong" ? 0.05 : 0.3, + unclear: 0, + }, + }, + ...Object.fromEntries( + Array.from( + { length: 4 }, + (_, index) => [`c${index}_distinct_option`, { type: "noul", noul: 0.95 }], + ), + ), + }; + } + let result = terminalPolicy(input); + expect(result.policyGate).toBe( + strength === "strong" ? "bare editor list accepted" : "bare editor list needs review", + ); + expect(result.events).toHaveLength(strength === "strong" ? 4 : 0); +}); + +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..310dfebf --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.ts @@ -0,0 +1,25 @@ +import { bareEditorList } from "./policy-bare-editor"; +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; + result = bareEditorList(context); + if (result) return result; + return purposeAndQuestion(context); +} 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..9f961115 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts @@ -0,0 +1,200 @@ +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; +} + +export function editorInput(): PolicyInput { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, terminalPolicy(opening).events[0]!, opening.message); + let threadId = state.threads[0]!.id; + let input = inputFor( + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + ); + input.state = state; + input.first = { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }; + for (let candidate of input.candidates) { + candidate.answers = { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + duplicate: { type: "noul", noul: 0.05 }, + }; + } + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 89835564..0460f036 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -193,6 +193,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | | Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | | Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | +| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -286,7 +287,12 @@ Thirty-one terminal results match the preserved offline policy, with two explici continuation comparisons and input-mutation checks. These checks cover this prefix, not the complete policy. Synthetic cap fixtures do not claim replay consistency. -Next: remaining ordered terminal stages, then candidate dispatch. Preserve shared +The remaining four terminal stages retain all 13 original statements and five whole +pipeline callbacks. Offline checks match 30 terminal outputs and four continuation +states, including input-mutation checks. Three injected quoted-option failures and +one changed-message capture regression are verified separately. + +Next: candidate setup, then candidate dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 7d063c509a2601df7dbfe78f504dcaa28b6fe1dc Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:39:30 +0100 Subject: [PATCH 024/152] Extract exact candidate grouping and competing-choice setup --- .../policy-candidate-setup.test.ts | 240 ++++++++++++++++++ .../policy-candidate-setup.ts | 96 +++++++ .../policy-candidate.test-fixtures.ts | 64 +++++ .../src/conversation-plan/policy-context.ts | 7 + docs/jev-product-extraction.md | 9 +- 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-setup.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-setup.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts 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.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-context.ts b/apps/server/src/conversation-plan/policy-context.ts index 0d1c085c..cf002f60 100644 --- a/apps/server/src/conversation-plan/policy-context.ts +++ b/apps/server/src/conversation-plan/policy-context.ts @@ -13,6 +13,12 @@ export type DirectFacts = { directBounds: boolean; topicThreeWay: boolean; }; +export type CandidateRun = { + seenOptionLabels: Set; + sequentialChoiceQuestions: boolean; + optionGroup: string | undefined; + competingMessageTargets: Set; +}; export type PolicyContext = { input: PolicyInput; channelId: string; @@ -24,6 +30,7 @@ export type PolicyContext = { working: State; selectedTarget: string | undefined; facts?: DirectFacts; + candidateRun?: CandidateRun; directQuestion?: boolean; quotedOption?: ( candidate: QuoteCandidate, diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 0460f036..ab4426db 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -194,6 +194,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | | Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | | Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | +| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -292,7 +293,13 @@ pipeline callbacks. Offline checks match 30 terminal outputs and four continuati states, including input-mutation checks. Three injected quoted-option failures and one changed-message capture regression are verified separately. -Next: candidate setup, then candidate dispatch. Preserve shared +Candidate setup retains six whole statements and four private run fields. Thirty-three +reached offline observations match the archived locals, including insertion order, +fresh sets, captured-message behavior and setup input-mutation checks. A synthetic +cached-label override is verified separately. The original full-policy callbacks +remain assigned to later dispatch slices; setup tests do not replace their assertions. + +Next: candidate dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From a8db3d7188124aba5f71393fcb72c5ff29b97850 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:49:11 +0100 Subject: [PATCH 025/152] Extract candidate entry and captured role checks with preserved skip semantics --- .../policy-candidate-entry.test-fixtures.ts | 38 ++++ .../policy-candidate-entry.test.ts | 120 ++++++++++ .../policy-candidate-entry.ts | 89 ++++++++ .../policy-candidate-role.test.ts | 205 ++++++++++++++++++ .../policy-candidate-role.ts | 191 ++++++++++++++++ docs/jev-product-extraction.md | 60 ++--- 6 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-entry.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-entry.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-role.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-role.ts 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..cadb93d0 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.test.ts @@ -0,0 +1,120 @@ +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("an event push failure preserves the successfully applied opening in working", () => { + let context = candidateContext(); + context.events.push = () => { + throw new Error("event push"); + }; + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeUndefined(); + expect(context.working.threads).toHaveLength(1); + expect(context.working.events).toHaveLength(1); + expect(context.events).toHaveLength(0); + expect(context.candidateRun!.optionGroup).toBeUndefined(); + expect(context.outcomes[0]!.gate).toBe("multi-option question could not be opened"); +}); + +test("an outcome ID push failure retains both the opening state and published event", () => { + let context = candidateContext(); + context.outcomes.push = function(this: typeof context.outcomes, ...outcomes) { + for (let outcome of outcomes) { + outcome.eventIds.push = () => { + throw new Error("ID push"); + }; + } + return Array.prototype.push.apply(this, outcomes); + }; + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeUndefined(); + expect(context.working.threads).toHaveLength(1); + expect(context.events).toHaveLength(1); + expect(context.outcomes[0]!.eventIds).toHaveLength(0); + expect(context.candidateRun!.optionGroup).toBeUndefined(); +}); + +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-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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index ab4426db..5fd1fd90 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,31 +170,32 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | -| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | -| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | -| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | -| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | -| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | -| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | -| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | +| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | +| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | +| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | +| Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -299,7 +300,14 @@ fresh sets, captured-message behavior and setup input-mutation checks. A synthet cached-label override is verified separately. The original full-policy callbacks remain assigned to later dispatch slices; setup tests do not replace their assertions. -Next: candidate dispatch. Preserve shared +Candidate entry and role retain 39 whole statements, with exactly eight outer-loop +continues mapped to candidate skips. Offline observations match 23 entry progressions, +seven entry skips, 13 role progressions and six role skips at their original boundaries; +two injected push failures reproduce the original write timing and are counted separately. +These comparisons cover intermediate state, events, outcomes, captures and input +mutation. Later factories, event proposals and follow-ups remain unimplemented. + +Next: candidate source/base factories and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 6ebbb2465c8bf6a9af138cfa1e004c007bb7b409 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:59:26 +0100 Subject: [PATCH 026/152] Extract live event factories and early verification handling --- .../policy-candidate-factories.test.ts | 82 ++++++++++ .../policy-candidate-factories.ts | 38 +++++ ...cy-candidate-verification.test-fixtures.ts | 65 ++++++++ .../policy-candidate-verification.test.ts | 140 ++++++++++++++++++ .../policy-candidate-verification.ts | 80 ++++++++++ docs/jev-product-extraction.md | 65 ++++---- 6 files changed, 442 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-factories.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-factories.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-verification.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-verification.ts 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-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..d51a8de6 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.test.ts @@ -0,0 +1,140 @@ +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(["events", "IDs"])( + "a %s push failure retains the successfully applied resume", + target => { + let { context, entry, role } = verificationFrame(); + if (target === "events") { + context.events.push = () => { + throw new Error("event push"); + }; + } else {entry.outcome.eventIds.push = () => { + throw new Error("ID push"); + };} + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + expect(context.working.events.at(-1)!.type).toBe("settle.resumed"); + expect(context.events).toHaveLength(target === "events" ? 0 : 1); + expect(entry.outcome.eventIds).toHaveLength(0); + expect(entry.outcome.gate).toBe("verification needs review"); + expect(activeSettleDeferral(context.working.threads[0]!, context.working.events)) + .toBeUndefined(); + }, +); + +test.each([false, true])( + "handled verification never reads the spike label after accepted/rejected inference: %s", + reject => { + let { context, entry, role } = verificationFrame(); + if (reject) { + context.events.push = () => { + throw new Error("event push"); + }; + } + let quote = entry.candidate.quote; + let reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + reads++; + if (reads > 2) throw new Error("spike label reached"); + return quote; + }, + }); + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + expect(reads).toBe(2); + expect(entry.outcome.gate).toBe(reject ? "verification needs review" : "accepted"); + }, +); + +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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 5fd1fd90..1f28f127 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,32 +170,33 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | -| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | -| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | -| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | -| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | -| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | -| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | -| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | -| Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | +| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | +| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | +| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | +| Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | +| Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -305,9 +306,17 @@ continues mapped to candidate skips. Offline observations match 23 entry progres seven entry skips, 13 role progressions and six role skips at their original boundaries; two injected push failures reproduce the original write timing and are counted separately. These comparisons cover intermediate state, events, outcomes, captures and input -mutation. Later factories, event proposals and follow-ups remain unimplemented. +mutation. Event proposals and follow-ups remain unimplemented. -Next: candidate source/base factories and later dispatch. Preserve shared +Candidate factories read the active handler's local working on each base call and +retain captured message/candidate references. Four factory creations, seven source +outputs and eight base outputs match the archived closures. Early verification checks +match four progressions and three handled skips; two mirrored push failures and two +independent quote-read controls are counted separately. Valid settlement histories +replay before and after resume. Continuation carries only the cached spike label, +not a base closure bound to the finished handler. Scoped handlers remain unimplemented. + +Next: scoped candidate handlers and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 0937d8d61203928342413338cf632e9b9f2c28a9 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:12:15 +0100 Subject: [PATCH 027/152] Extract scoped and direct new-choice policy handlers --- .../policy-candidate-new-choice.test.ts | 102 ++++++++++ .../policy-candidate-new-choice.ts | 73 ++++++++ .../policy-candidate-scoped.test-fixtures.ts | 119 ++++++++++++ .../policy-candidate-scoped.test.ts | 177 ++++++++++++++++++ .../policy-candidate-scoped.ts | 141 ++++++++++++++ docs/jev-product-extraction.md | 17 +- 6 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-new-choice.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-scoped.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-scoped.ts 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..3e900818 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts @@ -0,0 +1,102 @@ +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); +}); + +test("second source validation failure discards the local staged option", () => { + let { context, entry, role } = scopedFrame(newChoiceInput()); + let working = context.working; + let quote = entry.candidate.quote; + let reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + reads++; + return reads < 3 ? quote : "No longer matches the transcript."; + }, + }); + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(reads).toBe(3); + expect(context.working).toBe(working); + expect(context.events).toHaveLength(0); + expect(entry.outcome.gate).toBe("new choice could not be suggested"); +}); + +test.each(["events", "IDs"])( + "partial %s push failure preserves old working and already pushed data", + target => { + let { context, entry, role } = scopedFrame(newChoiceInput()); + let working = context.working; + if (target === "events") { + context.events.push = function(this: typeof context.events, ...events) { + Array.prototype.push.call(this, events[0]!); + throw new Error("partial event push"); + }; + } else {entry.outcome.eventIds.push = function(this: typeof entry.outcome.eventIds, ...ids) { + Array.prototype.push.call(this, ids[0]!); + throw new Error("partial ID push"); + };} + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(context.working).toBe(working); + expect(context.events).toHaveLength(target === "events" ? 1 : 2); + expect(entry.outcome.eventIds).toHaveLength(target === "events" ? 0 : 1); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe("new choice could not be suggested"); + }, +); 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-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..3a8168dd --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts @@ -0,0 +1,177 @@ +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.each([{ assent: true, target: "events" }, { assent: false, target: "events" }, { + assent: true, + target: "IDs", +}, { assent: false, target: "IDs" }])( + "$assent/$target push failure preserves applied scoped state", + ({ assent, target }) => { + let { context, entry, role, spike } = scopedFrame(scopedInput(assent)); + if (target === "events") { + context.events.push = () => { + throw new Error("event push"); + }; + } else {entry.outcome.eventIds.push = () => { + throw new Error("ID push"); + };} + let result = assent + ? runScopedAssent(context, entry, role) + : runScopedProposal(context, entry, role, spike); + expect(result).toBeUndefined(); + expect(context.working.events.at(-1)!.type).toBe( + assent ? "scoped-choice.agreed" : "scoped-choice.proposed", + ); + expect(context.events).toHaveLength(target === "events" ? 0 : 1); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe( + assent ? "scoped agreement needs review" : "scoped choice needs review", + ); + }, +); + +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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 1f28f127..83f0f9b6 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -197,6 +197,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | | Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | | Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | +| Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -314,9 +315,19 @@ outputs and eight base outputs match the archived closures. Early verification c match four progressions and three handled skips; two mirrored push failures and two independent quote-read controls are counted separately. Valid settlement histories replay before and after resume. Continuation carries only the cached spike label, -not a base closure bound to the finished handler. Scoped handlers remain unimplemented. - -Next: scoped candidate handlers and later dispatch. Preserve shared +not a base closure bound to the finished handler. + +Scoped assent, proposal and direct new-choice handlers retain seven whole statements +and five handled skips. Stopped-boundary observations match seven assent progressions +and two handled skips, two proposal progressions and three handled skips, and one +new-choice progression and five handled skips. Six mirrored push failures, one source-read +failure and one synthetic cached-label override are verified separately. Scoped writes +retain applied working before pushes; new choices publish their locally staged working +only after both pushes. Valid card, scoped-proposal and settlement histories replay; +synthetic prior-batch cap fixtures do not claim replay consistency. No complete original +pipeline callback has been replaced by these bounded tests. + +Next: ordinary candidate proposals and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 7b8d3146640e179016aef3054bcc1c6985d44f04 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:19:50 +0100 Subject: [PATCH 028/152] Extract withdrawal agreement and question policy proposals --- .../policy-candidate-agreement.test.ts | 64 ++++++++++++++ .../policy-candidate-agreement.ts | 47 +++++++++++ ...policy-candidate-ordinary.test-fixtures.ts | 68 +++++++++++++++ .../policy-candidate-question.test.ts | 83 +++++++++++++++++++ .../policy-candidate-question.ts | 48 +++++++++++ .../policy-candidate-withdrawal.test.ts | 83 +++++++++++++++++++ .../policy-candidate-withdrawal.ts | 51 ++++++++++++ docs/jev-product-extraction.md | 17 +++- 8 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-agreement.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-agreement.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-question.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-question.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-withdrawal.ts 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..5d561efd --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts @@ -0,0 +1,64 @@ +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 }); +}); +test.each(["base", "source"])("%s failure precedes selected-target assignment", failure => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("none")); + let before = structuredClone(entry.outcome); + if (failure === "base") { + context.working.threads.find = () => { + throw new Error("base read"); + }; + } else {Object.defineProperty(entry.candidate, "quote", { + get() { + throw new Error("source read"); + }, + });} + expect(() => runAgreement(context, entry, role)).toThrow(failure + " read"); + expect(context.selectedTarget).toBeUndefined(); + expect(entry.outcome).toEqual(before); +}); 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-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..05d09e9c --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-question.test.ts @@ -0,0 +1,83 @@ +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.each(["base", "source"])("question %s failure retains target assignment", failure => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("question")); + if (failure === "base") { + context.working.threads.find = () => { + throw new Error("base read"); + }; + } else {Object.defineProperty(entry.candidate, "quote", { + get() { + throw new Error("source read"); + }, + });} + expect(() => runQuestion(context, entry, role)).toThrow(failure + " read"); + expect(entry.outcome.targetId).toMatch(/^thread:/); + expect(context.events).toHaveLength(0); +}); + +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-withdrawal.test.ts b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts new file mode 100644 index 00000000..3b21d745 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts @@ -0,0 +1,83 @@ +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("source failure does not invent application or alter the outcome", () => { + let { context, entry, role } = ordinaryFrame(); + let before = structuredClone(entry.outcome); + let quote = entry.candidate.quote; + let reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + if (++reads === 3) throw new Error("quote read"); + return quote; + }, + }); + expect(() => runWithdrawal(context, entry, role)).toThrow("quote read"); + expect(entry.outcome).toEqual(before); + expect(context.events).toHaveLength(0); +}); + +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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 83f0f9b6..82ae1957 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -198,6 +198,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | | Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | | Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | +| Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -327,7 +328,21 @@ only after both pushes. Valid card, scoped-proposal and settlement histories rep synthetic prior-batch cap fixtures do not claim replay consistency. No complete original pipeline callback has been replaced by these bounded tests. -Next: ordinary candidate proposals and later dispatch. Preserve shared +Withdrawal, agreement and question branches retain their whole original consequent +bodies, with exactly three outer-loop continues mapped to handled skips. Handlers are +called only for their matching captured role; a returned object with no proposed event +progresses to the original no-proposal guard, whose continue bypasses the ordinary +loop-bottom cap. That guard remains assigned to the later dispatcher. Stopped +observations match two withdrawal +progressions and five handled skips, four agreement progressions, and three question +progressions and two handled skips. Five mirrored factory/source failures and two +synthetic cached-question/group controls are verified separately. The baseline stops +after the whole role chain, before its original no-proposal guard and application. +Live snapshots preserve target/proposal write timing even when event construction +throws. Saved discard and settlement histories replay; no event is applied by these +handlers. Remaining original full-policy callbacks stay assigned whole. + +Next: contribution, stance and resolution proposals and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 53c2e1823980d457d9461457fe3142e57edc1fe7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:28:54 +0100 Subject: [PATCH 029/152] Extract contribution proposal policy branch --- ...cy-candidate-contribution.test-fixtures.ts | 51 +++++ .../policy-candidate-contribution.test.ts | 214 ++++++++++++++++++ .../policy-candidate-contribution.ts | 89 ++++++++ docs/jev-product-extraction.md | 16 +- 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-contribution.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-contribution.ts 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..edf2c28b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts @@ -0,0 +1,214 @@ +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.each(["base", "source", "text"])("%s failure does not assign a proposal outcome", failure => { + let { context, entry, role } = contributionFrame(); + let before = structuredClone(entry.outcome); + let reads = 0; + if (failure === "base") { + context.working.threads.find = () => { + throw new Error("base read"); + }; + } else { + let quote = entry.candidate.quote; + Object.defineProperty(entry.candidate, "quote", { + get() { + if (++reads === (failure === "source" ? 1 : 2)) throw new Error(failure + " read"); + return quote; + }, + }); + } + if (failure !== "base") expect(reads).toBe(0); + expect(() => runContribution(context, entry, role)).toThrow(failure + " read"); + expect(entry.outcome).toEqual(before); + expect(context.events).toHaveLength(0); + if (failure !== "base") expect(reads).toBe(failure === "source" ? 1 : 2); +}); + +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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 82ae1957..c88c41fc 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -199,6 +199,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | | Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | | Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | +| Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -342,7 +343,20 @@ Live snapshots preserve target/proposal write timing even when event constructio throws. Saved discard and settlement histories replay; no event is applied by these handlers. Remaining original full-policy callbacks stay assigned whole. -Next: contribution, stance and resolution proposals and later dispatch. Preserve shared +Contribution proposals retain eight whole statements and four handled skips. The +handler is called only for its captured option, reason or constraint role. Nineteen +progressions and six handled skips match the stopped archived role chain; five +synthetic capture/group controls and separate base, source and quoted-text construction +failures are counted separately. Tests distinguish fresh card-map lookups from captured +thread contributions and pending qualification. Existing card names require the +original card identity and unique-name match, and the duplicate threshold retains its +option-group exemption. Valid histories and proposed contributions replay. Unknown +relations are omitted, and unsafe quote filters remain limited to option proposals. +These handlers apply no event; the original no-proposal guard, application and loop +cap remain assigned to later slices. Original full-policy callbacks remain assigned +whole. + +Next: stance and resolution proposals and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 6db8e58f71f440c9c619dcf42bb8bf59a50e5fb2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:37:39 +0100 Subject: [PATCH 030/152] Extract support and objection policy proposals --- .../policy-candidate-stance-scoped.test.ts | 92 ++++++++ .../policy-candidate-stance.test-fixtures.ts | 107 +++++++++ .../policy-candidate-stance.test.ts | 219 ++++++++++++++++++ .../policy-candidate-stance.ts | 121 ++++++++++ docs/jev-product-extraction.md | 17 +- 5 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-stance.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-stance.ts 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..7bc9a0df --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.test.ts @@ -0,0 +1,219 @@ +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.each(["base", "source", "text"])( + "reopening %s construction failure precedes review publication", + failure => { + let { context, entry, role } = stanceFrame(decidedInput()); + let before = structuredClone(entry.outcome), reads = 0; + if (failure === "base") { + context.working.threads.find = () => { + throw new Error("base read"); + }; + } else { + let quote = entry.candidate.quote; + Object.defineProperty(entry.candidate, "quote", { + get() { + if (++reads === (failure === "source" ? 1 : 2)) throw new Error(failure + " read"); + return quote; + }, + }); + } + expect(() => runStance(context, entry, role)).toThrow(failure + " read"); + expect(reads).toBe(failure === "base" ? 0 : failure === "source" ? 1 : 2); + expect(context.reviews).toHaveLength(0); + expect(context.events).toHaveLength(0); + expect(entry.outcome).toEqual(before); + }, +); +test.each([false, true])("reopening review push failure preserves partial review $0", partial => { + let { context, entry, role } = stanceFrame(decidedInput()); + let before = structuredClone(entry.outcome), reads = 0, quote = entry.candidate.quote; + Object.defineProperty(entry.candidate, "quote", { + get() { + reads++; + return quote; + }, + }); + context.reviews.push = function(this: typeof context.reviews, ...items) { + expect(reads).toBe(2); + if (partial) Array.prototype.push.call(this, items[0]!); + throw new Error("review push"); + }; + expect(() => runStance(context, entry, role)).toThrow("review push"); + expect(context.reviews).toHaveLength(partial ? 1 : 0); + expect(entry.outcome).toEqual(before); + expect(context.events).toHaveLength(0); +}); + +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..eafbeeca --- /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.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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index c88c41fc..ea1d4a1b 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -200,6 +200,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | | Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | | Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | +| Support and objection proposals | Two exact consequent statements preserve order and two handled skips. Captured card uniqueness and option fallback, material reopening, current scoped history, historical agreement after withdrawal and partial review publication are covered. | 377 conversation tests, 2,028 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -356,7 +357,21 @@ These handlers apply no event; the original no-proposal guard, application and l cap remain assigned to later slices. Original full-policy callbacks remain assigned whole. -Next: stance and resolution proposals and later dispatch. Preserve shared +Support and objection proposals retain two whole statements and two handled skips. +Twenty-two progressions and one handled skip match the stopped archived role chain; +six synthetic capture/review controls, separate base/source/text failures and two +mirrored review-push failures are counted separately. Replay-valid decided and scoped +agreement histories cover the 0.68 material-objection threshold and captured card +uniqueness, including bidirectional substring collisions and exact contribution text. +Scoped objection ownership still accepts an earlier agreement after active support +withdrawal; this inherited rule has not been strengthened. Support always leaves the +scoped proposal link null. Source parity proves proposal assignment precedes review +publication; thrown-push comparisons cover observable partial writes and error, without +claiming direct equality of the inaccessible product proposal local. Matching-role +wrappers retain the original branch type narrowing. No event is applied and no +full-policy callback is replaced. + +Next: resolution and reopening proposals and later dispatch. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From d4bee822220cd57a634243612f2a964c2327b1df Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:49:07 +0100 Subject: [PATCH 031/152] Extract resolution and reopening policy proposals --- .../policy-candidate-reopening.test.ts | 76 +++++++++ .../policy-candidate-reopening.ts | 47 ++++++ ...ndidate-resolution-materialization.test.ts | 99 ++++++++++++ ...licy-candidate-resolution.test-fixtures.ts | 38 +++++ .../policy-candidate-resolution.test.ts | 147 ++++++++++++++++++ .../policy-candidate-resolution.ts | 107 +++++++++++++ docs/jev-product-extraction.md | 18 ++- 7 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-reopening.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-reopening.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-resolution.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-resolution.ts 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..9200f4a7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts @@ -0,0 +1,76 @@ +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"); +}); +test.each(["base", "source", "text"])( + "reopening %s construction failure precedes review push", + failure => { + let { context, entry, role } = resolutionFrame(reopeningInput()); + let quote = entry.candidate.quote, reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + reads++; + if (failure !== "base" && reads === (failure === "source" ? 2 : 3)) { + throw new Error(failure + " read"); + } + return quote; + }, + }); + if (failure === "base") { + context.working.threads.find = () => { + throw new Error("base read"); + }; + } + expect(() => runReopening(context, entry, role)).toThrow(failure + " read"); + expect(reads).toBe(failure === "base" ? 1 : failure === "source" ? 2 : 3); + expect(context.reviews).toHaveLength(0); + expect(context.events).toHaveLength(0); + }, +); +test.each([false, true])("reopening review push failure preserves partial review $0", partial => { + let { context, entry, role } = resolutionFrame(reopeningInput()); + let quote = entry.candidate.quote, reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + reads++; + return quote; + }, + }); + context.reviews.push = function(this: typeof context.reviews, ...items) { + expect(reads).toBe(3); + if (partial) Array.prototype.push.call(this, items[0]!); + throw new Error("review push"); + }; + expect(() => runReopening(context, entry, role)).toThrow("review push"); + expect(context.reviews).toHaveLength(partial ? 1 : 0); + expect(context.events).toHaveLength(0); + expect(entry.outcome.status).toBe("ignored"); +}); 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..0a09f773 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts @@ -0,0 +1,99 @@ +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); +}); +test.each([3, 4])("invalid source at quote read%s retains original working", read => { + let { context, entry, role } = resolutionFrame(resolutionInput(true)); + let before = context.working, quote = entry.candidate.quote, reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + return ++reads === read ? "Changed quote." : quote; + }, + }); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(reads).toBe(read); + expect(context.working).toBe(before); + expect(context.events).toHaveLength(0); + expect(entry.outcome.eventIds).toHaveLength(0); + expect(entry.outcome.gate).toBe("linked card choice could not be suggested"); +}); +test.each([3, 4])("thrown source at quote read%s preserves original catch boundary", read => { + let { context, entry, role } = resolutionFrame(resolutionInput(true)); + let before = context.working, quote = entry.candidate.quote, reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + if (++reads === read) throw new Error("source read" + read); + return quote; + }, + }); + if (read === 3) { + expect(() => runResolution(context, entry, role)).toThrow("source read3"); + expect(entry.outcome.gate).toBe("no useful role"); + } else { + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("linked card choice could not be suggested"); + } + expect(reads).toBe(read); + expect(context.working).toBe(before); + expect(context.events).toHaveLength(0); +}); +test("existing-choice source exception remains outside inference/application", () => { + let { context, entry, role } = resolutionFrame(); + let quote = entry.candidate.quote, reads = 0; + Object.defineProperty(entry.candidate, "quote", { + get() { + if (++reads === 3) throw new Error("existing source read"); + return quote; + }, + }); + expect(() => runResolution(context, entry, role)).toThrow("existing source read"); + expect(reads).toBe(3); + expect(context.events).toHaveLength(0); + expect(entry.outcome.status).toBe("ignored"); +}); +test.each(["events", "IDs"])( + "%s push failure retains applied materialization and partial data", + target => { + let { context, entry, role } = resolutionFrame(resolutionInput(true)); + let before = context.working; + if (target === "events") { + context.events.push = function(this: typeof context.events, ...events) { + Array.prototype.push.call(this, events[0]!); + throw new Error("event push"); + }; + } else {entry.outcome.eventIds.push = function(this: string[], ...ids) { + Array.prototype.push.call(this, ids[0]!); + throw new Error("ID push"); + };} + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(context.working).not.toBe(before); + expect(context.working.threads[0]!.pendingSettle?.optionId).toBe("beta"); + expect(context.events).toHaveLength(target === "events" ? 1 : 2); + expect(entry.outcome.eventIds).toHaveLength(target === "events" ? 0 : 1); + expect(entry.outcome.gate).toBe("linked card choice could not be suggested"); + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index ea1d4a1b..f9ec181d 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -201,6 +201,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | | Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | | Support and objection proposals | Two exact consequent statements preserve order and two handled skips. Captured card uniqueness and option fallback, material reopening, current scoped history, historical agreement after withdrawal and partial review publication are covered. | 377 conversation tests, 2,028 assertions | +| Resolution and reopening | Fourteen resolution statements and one reopening conditional retain six handled skips. Credibility, fresh chosen/card lookup, captured targets, pending conflicts, staged linked-card construction and partial publication are covered. | 411 conversation tests, 2,271 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -371,7 +372,22 @@ claiming direct equality of the inaccessible product proposal local. Matching-ro wrappers retain the original branch type narrowing. No event is applied and no full-policy callback is replaced. -Next: resolution and reopening proposals and later dispatch. Preserve shared +Resolution and explicit reopening retain fourteen statements and one whole conditional, +with six handled skips. Stopped observations match three resolution progressions and +nine handled results, and three reopening progressions. Seven capture/seed controls +(one progression and six handled results, including the seeded reopening duplicate) +and twelve mirrored construction, validation and publication failures are counted +separately. The discarded-frame control explicitly replaces the captured frame in +the temporary baseline; the original prefix skips an initially discarded target. +Linked-card choices stage construction, preserve the original-version base with the +staged-version override, and assign applied working before event and ID pushes. A +second inference failure retains original working; later push failure retains applied +working and partial publication. This path provides no rollback after publication. +Explicit reopening preserves proposal-before-review ordering. Ordinary application, +the no-proposal guard and loop-bottom cap remain unimplemented. Whole original +full-policy callbacks remain assigned to the eventual dispatcher. + +Next: ordinary application, follow-ups and the remaining dispatcher. Preserve shared candidate state, local capture timing, continuation and event-limit checks, and partial events retained after follow-up failure. Keep the original public interface and compare original offline fixtures before publishing the dispatcher. From 5b85392862bd866d01e2119c105332756dcbdb35 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:00:53 +0100 Subject: [PATCH 032/152] Extract ordinary policy event application and follow-ups --- ...icy-candidate-application-deferral.test.ts | 76 +++++++++++ ...icy-candidate-application-failures.test.ts | 114 +++++++++++++++++ ...icy-candidate-application.test-fixtures.ts | 91 ++++++++++++++ .../policy-candidate-application.test.ts | 111 ++++++++++++++++ .../policy-candidate-application.ts | 119 ++++++++++++++++++ docs/jev-product-extraction.md | 32 +++-- 6 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-application.test.ts create mode 100644 apps/server/src/conversation-plan/policy-candidate-application.ts 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..af68241f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { + applicationFrame, + deferralInput, + supportInput, +} from "./policy-candidate-application.test-fixtures"; +import { replay } from "./domain"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; +import type { Event } from "./policy-types"; + +// Failure controls preserve applied state and partial publication; no rollback is added. +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.each(["events", "ids"] as const)( + "main %s push failure retains applied state and partial publication", + field => { + let frame = applicationFrame(supportInput([])); + if (field === "events") { + frame.context.events.push = function(...items: Event[]) { + Array.prototype.push.apply(this, items); + throw new Error("publication failed"); + }; + } else {frame.entry.outcome.eventIds.push = function(...items: string[]) { + Array.prototype.push.apply(this, items); + throw new Error("publication failed"); + };} + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.working.events.at(-1)!.type).toBe("stance.changed"); + expect(frame.context.events).toHaveLength(1); + expect(frame.entry.outcome.eventIds).toHaveLength(field === "ids" ? 1 : 0); + expect(frame.entry.outcome.status).toBe("review"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); + }, +); +test.each(["thread.leaning", "settle.agreed"] as const)( + "%s publication failure retains each completed inference", + type => { + let frame = applicationFrame(supportInput(["Jules"], true)); + frame.context.events.push = function(...items: Event[]) { + let length = Array.prototype.push.apply(this, items); + if (items[0]!.type === type) throw new Error("follow-up publication failed"); + return length; + }; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.working.events.at(-1)!.type).toBe(type); + expect(frame.context.events.at(-1)!.type).toBe(type); + expect(frame.entry.outcome.eventIds).toHaveLength(type === "thread.leaning" ? 1 : 2); + expect(frame.entry.outcome.gate).toBe("domain rejected proposed event"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); + }, +); +test.each(["stance.changed", "thread.leaning"] as const)( + "source mutation after %s publication rejects the next inference without rollback", + type => { + let frame = applicationFrame(supportInput(["Jules"], true)); + frame.context.events.push = function(...items: Event[]) { + let length = Array.prototype.push.apply(this, items); + let item = items[0]!; + if (item.type === type && "source" in item) { + item.source.quote = "Corrupted after publication."; + } + return length; + }; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.working.events.at(-1)!.type).toBe(type); + expect(frame.context.events.at(-1)!.type).toBe(type); + expect(frame.entry.outcome.eventIds).toHaveLength(type === "stance.changed" ? 1 : 2); + expect(frame.entry.outcome.status).toBe("review"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); + }, +); +test.each([1, 2])( + "deferral quote getter failure on read %i retains the main constraint", + failingRead => { + let { input, neutral } = deferralInput(); + let frame = applicationFrame(input, [neutral]), reads = 0; + let quote = frame.entry.candidate.quote; + Object.defineProperty(frame.entry.candidate, "quote", { + configurable: true, + get() { + reads++; + if (reads === failingRead) throw new Error("follow-up quote failed"); + return quote; + }, + }); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(reads).toBe(failingRead); + expect(frame.context.working.events.at(-1)!.type).toBe("constraint.added"); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + ]); + expect(frame.entry.outcome.eventIds).toHaveLength(1); + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index f9ec181d..d372bf07 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -202,6 +202,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | | Support and objection proposals | Two exact consequent statements preserve order and two handled skips. Captured card uniqueness and option fallback, material reopening, current scoped history, historical agreement after withdrawal and partial review publication are covered. | 377 conversation tests, 2,028 assertions | | Resolution and reopening | Fourteen resolution statements and one reopening conditional retain six handled skips. Credibility, fresh chosen/card lookup, captured targets, pending conflicts, staged linked-card construction and partial publication are covered. | 411 conversation tests, 2,271 assertions | +| Ordinary application | The exact whole try/catch retains main application, raw pending deferral, distinct-supporter leaning and agreement follow-ups. Active-local versions, original support caps, uncapped constraint deferral and partial publication after caught failure are covered. | 440 conversation tests, 2,490 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -383,11 +384,28 @@ Linked-card choices stage construction, preserve the original-version base with staged-version override, and assign applied working before event and ID pushes. A second inference failure retains original working; later push failure retains applied working and partial publication. This path provides no rollback after publication. -Explicit reopening preserves proposal-before-review ordering. Ordinary application, -the no-proposal guard and loop-bottom cap remain unimplemented. Whole original -full-policy callbacks remain assigned to the eventual dispatcher. - -Next: ordinary application, follow-ups and the remaining dispatcher. Preserve shared -candidate state, local capture timing, continuation and event-limit checks, and +Explicit reopening preserves proposal-before-review ordering. The no-proposal guard +and loop-bottom cap remain unimplemented. Whole original full-policy callbacks remain +assigned to the eventual dispatcher. + +Ordinary application retains the exact whole try/catch from archive policy.ts1634–1726, +without skip mappings. Its internal handler requires a definite event after the original +no-proposal guard and uses fresh factories bound to active local working. Stopped +observations match ten normal application results; nine batch/capture seeds and ten +mirrored inference, source and publication failures are counted separately. All four +temporary hook sites are checked exactly once, and every selected case reaches the +pre-application boundary. Observations stop after the try/catch before the original +loop-bottom cap and compare state, publications, outcomes, reviews and input mutations. + +The original main/follow-up inference assigns working before publication; caught failures +retain applied state and partial event/ID writes. Constraint deferral reads raw pending +state after same-message neutral withdrawal and has no support-block event cap: an +explicit synthetic eleven-event batch reaches thirteen. Leaning uses distinct supporters, +and agreement retains the prior captured current reference with updated working history. +Only the exact no-useful-role gate changes to the accepted/review status. No ordinary +application dispatcher, no-proposal guard, loop cap or public policy facade is activated. + +Next: the no-proposal guard, loop-bottom cap, final fallback and complete dispatcher. +Preserve shared candidate state, capture timing, original continue/break targets and partial events retained after follow-up failure. Keep the original public interface -and compare original offline fixtures before publishing the dispatcher. +and compare complete original offline fixtures before publishing the dispatcher. From c377340653f48c41440de090a3506b0d3f967d28 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:12:17 +0100 Subject: [PATCH 033/152] Complete pure conversation policy dispatcher and fallback --- .../policy-dispatcher.test-fixtures.ts | 52 +++++++ .../policy-dispatcher.test.ts | 138 ++++++++++++++++++ .../conversation-plan/policy-final.test.ts | 87 +++++++++++ .../src/conversation-plan/policy-final.ts | 64 ++++++++ .../src/conversation-plan/policy-proposal.ts | 36 +++++ apps/server/src/conversation-plan/policy.ts | 45 ++++++ docs/jev-product-extraction.md | 103 ++++++++----- 7 files changed, 488 insertions(+), 37 deletions(-) create mode 100644 apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/policy-dispatcher.test.ts create mode 100644 apps/server/src/conversation-plan/policy-final.test.ts create mode 100644 apps/server/src/conversation-plan/policy-final.ts create mode 100644 apps/server/src/conversation-plan/policy-proposal.ts create mode 100644 apps/server/src/conversation-plan/policy.ts 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..b921fd77 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-dispatcher.test.ts @@ -0,0 +1,138 @@ +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, editorInput } 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"], + ["bare editor", editorInput, "bare editor list 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-final.test.ts b/apps/server/src/conversation-plan/policy-final.test.ts new file mode 100644 index 00000000..cbb0d81b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-final.test.ts @@ -0,0 +1,87 @@ +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"; +import type { Event } from "./policy-types"; + +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.each([false, true])( + "fallback events push failure retains partial publication: %s", + partial => { + let context = finalContext(), before = context.working; + context.events.push = function(...items: Event[]) { + if (partial) Array.prototype.push.apply(this, items); + throw new Error("event push failed"); + }; + let result = finishPolicy(context); + expect(result.events).toHaveLength(partial ? 1 : 0); + expect(result.outcomes[1]!.status).toBe("review"); + expect(result.outcomes[1]!.gate).toBe("question could not be opened"); + expect(context.working).toBe(before); + }, +); +test("fallback ID push failure keeps the event and partial ID but marks review", () => { + let context = finalContext(); + context.outcomes[1]!.eventIds.push = function(...items: string[]) { + Array.prototype.push.apply(this, items); + throw new Error("ID push failed"); + }; + let result = finishPolicy(context); + expect(result.events).toHaveLength(1); + expect(result.outcomes[1]!.eventIds).toEqual([result.events[0]!.id]); + expect(result.outcomes[1]!.status).toBe("review"); +}); +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-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.ts b/apps/server/src/conversation-plan/policy.ts new file mode 100644 index 00000000..0e22298e --- /dev/null +++ b/apps/server/src/conversation-plan/policy.ts @@ -0,0 +1,45 @@ +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 type { PolicyInput, PolicyResult } from "./policy-types"; + +export { bareEditorClarificationThread } from "./policy-clarification"; +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; + } + return finishPolicy(context); +} diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index d372bf07..2adf2f95 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -170,39 +170,40 @@ missing-export checks failed before extraction, then passed. The table records t independent focused results on Bun 1.4.2; every slice also passed full workspace and E2E type checks, formatting and CI with the existing warnings listed above. -| Slice | Preserved behavior and evidence | Focused result | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | -| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | -| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | -| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | -| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | -| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | -| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | -| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | -| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | -| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | -| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | -| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | -| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | -| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | -| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | -| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | -| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | -| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | -| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | -| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | -| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | -| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | -| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | -| Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | -| Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | -| Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | -| Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | -| Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | -| Support and objection proposals | Two exact consequent statements preserve order and two handled skips. Captured card uniqueness and option fallback, material reopening, current scoped history, historical agreement after withdrawal and partial review publication are covered. | 377 conversation tests, 2,028 assertions | -| Resolution and reopening | Fourteen resolution statements and one reopening conditional retain six handled skips. Credibility, fresh chosen/card lookup, captured targets, pending conflicts, staged linked-card construction and partial publication are covered. | 411 conversation tests, 2,271 assertions | -| Ordinary application | The exact whole try/catch retains main application, raw pending deferral, distinct-supporter leaning and agreement follow-ups. Active-local versions, original support caps, uncapped constraint deferral and partial publication after caught failure are covered. | 440 conversation tests, 2,490 assertions | +| Slice | Preserved behavior and evidence | Focused result | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| Stored identity | Original `storedText()`/`identified()` and public export; IDs, order and raw strings survive without mutation. Normalisation and draft behavior stay current. | 33 question tests, 99 assertions | +| Contract and sources | Complete archived `ConversationPlan` namespace, type export and `sources.ts`; exact UTF-16 spans, saved author/text identity and completed messages. Global wire unions and session flags stay current. | 6 source tests, 53 assertions | +| Quotes | Byte-identical parser/budget, all four original test bodies, plus explicit fifth-candidate rejection. Five exact development messages have source provenance. | 5 tests, 20 assertions | +| Event/correction guards | Original functions in bounded modules with an acyclic façade; all event and correction cases remain. | 7 tests, 88 assertions | +| Research/snapshot guards | Completes the original validation façade. Root syntax-tree proof: all 17 original functions and six constants occur once and match. | 12 tests, 81 assertions | +| Accepted-event replay | All 21 original case bodies, support functions, opening/preamble and counters match source syntax trees; `preference.ts` is byte-identical. Routing covers each case once. | 9 tests, 51 assertions | +| Pure domain | All 18 archived functions and 18 original test callbacks/helpers match source syntax trees. Small, acyclic modules retain the original 11 public APIs. | 18 tests, 58 assertions | +| Research-offer regressions | All 10 original tests and helper data match source syntax trees across three small suites; no production changes or eval imports. | 10 tests, 72 assertions | +| Card and Save regressions | All 17 original card tests and helpers match source syntax trees; four scoped Save tests are byte-identical. Card authority and exact support lineage remain unchanged. | 21 tests, 71 assertions | +| Option growth | Byte-identical archived helper and six original tests; limit/export additions only. Cloned drafts keep existing selections and labels grow in order. | 39 question tests, 110 assertions | +| Answer identifiers | Archived derivation adds IDs alongside labels from the same ordered selection; optional protocol field retains old answers. Custom answers and summaries stay unchanged. | 64 question/service tests, 171 assertions | +| Record validation | All 25 original function/constant/type declarations match source syntax trees; seven archived tests are byte-identical. Extracted required dialect MAX_ID=200; added UTF-16 boundary regression. | 8 record tests, 29 assertions | +| Pending-card drafts | Whole draft implementation matches archive; restored original empty-card mode assertions and added first-option selection/ID regression. Existing stored modes stay unchanged. | 73 focused tests, 205 assertions | +| Shared definitions | Store restoration and controller Open.Reply use identified definitions; existing wire types cover them. One original Store callback/helper and five new regressions cover stable IDs, pending cards and malformed payloads. | 79 focused tests, 226 assertions | +| Store options/lifecycle | Small acyclic modules preserve archived APIs/types, capture/revert, reopening and editor credit. Nine of 19 original Store callbacks are retained; 17 other core bodies remain unchanged apart from editor initialisation. | 99 focused tests, 292 assertions | +| Store suggestions | Archived suggestion metadata, restore bounds and explicit submit fallback; all 47 Store declarations and 19 original callbacks are retained exactly once across small suites. Existing service callers remain unchanged. | 109 focused tests, 348 assertions | +| Durable record restore | Canonical record defaults, matching saved definitions/drafts and transcript-checked option sources are integrated into current sidecar restoration. Ten memory persistence regressions; current metadata and mutation bodies remain intact. | 129 focused tests, 417 assertions | +| Request builders | All 18 archived declarations and exact prompts are split across acyclic modules. Thirteen original builder callbacks and nine fixture declarations are retained; two interpreter cases remain assigned to their dependency slice. | 111 conversation tests, 580 assertions | +| Review/correction coverage | All 19 original callbacks and 18 actor/helper declarations are retained, including the complete three-case excerpt table. Twenty-one cases exercise existing pure behavior; production is unchanged. | 132 conversation tests, 669 assertions | +| Policy leaves | All 24 archived helper/type/constant declarations are retained across five small acyclic modules. One exact pipeline callback verifies deterministic ULID identity and Unix-second encoding. No policy dispatcher or placeholder exists yet. | 133 conversation tests, 673 assertions | +| Initial policy terminals | All 20 original preamble/fact/gate statements are retained with ordered internal calls; one unset initializer is explicitly undefined for lint. Three whole pipeline callbacks cover 18 cases; bounded regressions cover ordering, staging, capture and continuation. | 171 conversation tests, 801 assertions | +| Remaining policy terminals | All 13 original statements through the purpose/final-question gate are retained across four stages and an ordered runner. Five whole pipeline callbacks remain exact; working writeback, local staging, cached captures and explicit continuation are covered. | 192 conversation tests, 924 assertions | +| Candidate setup | All six original statements retain order and captured inputs; four private mutable run fields are cached, with the prior-choice map local. Grouping, distinct known/linked/novel choices, evidence thresholds, fresh sets and input-state provenance are covered. | 224 conversation tests, 1,035 assertions | +| Candidate entry and role | All six entry and 33 role statements retain original conditions and order, with eight candidate-loop continues mapped to undefined skips. Captured references, group retargeting, consumed labels, partial opening writes and selected-target preservation are covered. | 251 conversation tests, 1,191 assertions | +| Candidate factories/verification | Exact source/base declarations and eight early verification/condition statements retain capture timing and handled skips. Active-local working reads, valid deferred-domain histories, partial resume writes and cached spike labels are covered. | 266 conversation tests, 1,288 assertions | +| Scoped and new-choice handlers | All seven original statements preserve order, with exactly five handled skips. Scoped captures and labels, replay-valid histories, active-local factories, staged version overrides and partial push failures are covered. | 293 conversation tests, 1,484 assertions | +| Withdrawal, agreement, question | Three exact ordinary-role consequent bodies preserve statement order and three handled skips. Captured withdrawal ownership, current effective pending, raw option answers, question thresholds and target assignment before/after factory evaluation are covered. | 316 conversation tests, 1,629 assertions | +| Contribution proposals | Eight exact consequent statements preserve order and four handled skips. Fresh card lookups, captured contribution/pending targets, role-specific quote filters, duplicate exemption, relation omission and construction timing are covered. | 343 conversation tests, 1,821 assertions | +| Support and objection proposals | Two exact consequent statements preserve order and two handled skips. Captured card uniqueness and option fallback, material reopening, current scoped history, historical agreement after withdrawal and partial review publication are covered. | 377 conversation tests, 2,028 assertions | +| Resolution and reopening | Fourteen resolution statements and one reopening conditional retain six handled skips. Credibility, fresh chosen/card lookup, captured targets, pending conflicts, staged linked-card construction and partial publication are covered. | 411 conversation tests, 2,271 assertions | +| Ordinary application | The exact whole try/catch retains main application, raw pending deferral, distinct-supporter leaning and agreement follow-ups. Active-local versions, original support caps, uncapped constraint deferral and partial publication after caught failure are covered. | 440 conversation tests, 2,490 assertions | +| Complete pure policy | Final fallback, bounded gate and result retain three whole nodes. All seven proposal route conditions, original candidate-loop binding, no-proposal continue and loop-bottom break preserve exact source tests and order. Complete offline public outputs and separately seeded final failures are covered. | 474 conversation tests, 2,606 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -405,7 +406,35 @@ and agreement retains the prior captured current reference with updated working Only the exact no-useful-role gate changes to the accepted/review status. No ordinary application dispatcher, no-proposal guard, loop cap or public policy facade is activated. -Next: the no-proposal guard, loop-bottom cap, final fallback and complete dispatcher. -Preserve shared candidate state, capture timing, original continue/break targets and -partial events retained after follow-up failure. Keep the original public interface -and compare complete original offline fixtures before publishing the dispatcher. +The complete pure public `planEvents(input)` runner now connects the reviewed stages in +original order: context and terminals, candidate setup, entry and role capture, cached +verification, scoped assent/proposal, direct new choice, matching ordinary proposals, +no-proposal continue, application and loop-bottom break. Every earlier handled skip +bypasses that cap. All seven role-routing conditions retain the exact original tests; +unknown truthy roles produce no proposal. The final fallback, 300-character gate and +result retain their three whole original nodes. Fallback uses live ownership/role +answers with cached direct-question state, index-zero IDs and full-message source; +validation does not assign returned working. Normal results retain the own selectedTarget +property even when undefined, while earlier terminal property omissions are unchanged. + +Complete output comparisons match 57 ordinary public invocations and one quote-budget +rejection against the preserved original policy, using strict equality for full nested +results and input clones. They use complete real-input execution without injected stage +state. Independent review also matches 75 strict complete outputs across 21 fixture +families at ownership thresholds 0.69, 0.7 and 0.95, three input shapes and nine +optionless stance controls, with zero seeds or input mutations. These independent observations are reported separately. +Five synthetic final-context observations and three receiver-based publication failures +are counted separately, with one exact reached final-boundary hook per invocation. +The four-quote budget remains intact; enlarged publication batches from earlier unit +slices are synthetic controls rather than complete public comparisons. + +Original pipeline ownership remains nine of 140 whole callback nodes retained and 131 +explicitly deferred; existing callback bodies and repository imports are unchanged. +Temporary validation redirects retained terminal callbacks through the complete public +runner without rewriting them. The original public exports remain PolicyInput, +PolicyResult, optionIdFor, bareEditorClarificationThread and planEvents. No runtime +caller, configuration, paid provider or service activation is added. + +Next: bounded original interpreter and policy regression groups, followed by remaining +pure/provider boundaries and runtime integration. Preserve whole callback ownership +and compare complete offline outputs before adding consumers. From d69d29128b69374b979b472d80222d76b0da87e1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:22:21 +0100 Subject: [PATCH 034/152] Restore bounded conversation interpreter and original regressions --- .../conversation-plan/interpret-batch.test.ts | 62 +++++ .../conversation-plan/interpret-order.test.ts | 41 +++ .../conversation-plan/interpret-research.ts | 84 ++++++ .../conversation-plan/interpret-scoring.ts | 23 ++ .../interpret-source.test.ts | 69 +++++ .../interpret-targeting.test.ts | 113 ++++++++ .../src/conversation-plan/interpret-types.ts | 31 +++ .../interpret.test-fixtures.ts | 141 ++++++++++ .../server/src/conversation-plan/interpret.ts | 241 ++++++++++++++++++ .../policy-D19-fourth-candidate.test.ts | 123 +++++++++ .../question-interpreter.test.ts | 118 +++++++++ docs/jev-product-extraction.md | 30 ++- 12 files changed, 1073 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/conversation-plan/interpret-batch.test.ts create mode 100644 apps/server/src/conversation-plan/interpret-order.test.ts create mode 100644 apps/server/src/conversation-plan/interpret-research.ts create mode 100644 apps/server/src/conversation-plan/interpret-scoring.ts create mode 100644 apps/server/src/conversation-plan/interpret-source.test.ts create mode 100644 apps/server/src/conversation-plan/interpret-targeting.test.ts create mode 100644 apps/server/src/conversation-plan/interpret-types.ts create mode 100644 apps/server/src/conversation-plan/interpret.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/interpret.ts create mode 100644 apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts create mode 100644 apps/server/src/conversation-plan/question-interpreter.test.ts 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..5cb27609 --- /dev/null +++ b/apps/server/src/conversation-plan/interpret.ts @@ -0,0 +1,241 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { askJev, type JevAnswer } from "./jev"; +import { bareEditorClarificationThread, planEvents } from "./policy"; +import { + BARE_EDITOR_CLARIFICATION_VERSION, + buildBareEditorClarificationRequest, + 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, + )); + 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, + }); + if (planned.policyGate === "bare editor list needs review") { + let policyInput = { + channelId: input.channelId, + message: input.message, + state: input.state, + linkedCards, + first: first.answers, + candidates, + }; + let thread = bareEditorClarificationThread(policyInput); + if (thread) { + try { + let clarified = await ask(buildBareEditorClarificationRequest( + input.message, + thread, + quotes, + )); + if (clarified.model === first.model) { + passes.push({ + stage: "clarification", + version: BARE_EDITOR_CLARIFICATION_VERSION, + answers: clarified.answers, + }); + planned = planEvents({ ...policyInput, clarification: clarified.answers }); + } + } catch { + // An unavailable joint judgment cannot authorize any of the four options. + } + } + } + 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/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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 2adf2f95..6097e8a0 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -204,6 +204,7 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Resolution and reopening | Fourteen resolution statements and one reopening conditional retain six handled skips. Credibility, fresh chosen/card lookup, captured targets, pending conflicts, staged linked-card construction and partial publication are covered. | 411 conversation tests, 2,271 assertions | | Ordinary application | The exact whole try/catch retains main application, raw pending deferral, distinct-supporter leaning and agreement follow-ups. Active-local versions, original support caps, uncapped constraint deferral and partial publication after caught failure are covered. | 440 conversation tests, 2,490 assertions | | Complete pure policy | Final fallback, bounded gate and result retain three whole nodes. All seven proposal route conditions, original candidate-loop binding, no-proposal continue and loop-bottom break preserve exact source tests and order. Complete offline public outputs and separately seeded final failures are covered. | 474 conversation tests, 2,606 assertions | +| Pure interpreter | The original research guard, request await and catch remain in main. Synchronous offer selection preserves the original model conditional, seven types/helpers, targeting batch order and failure handling. Six whole pipeline callbacks, deferred question loop and original D19 suite are restored. | 485 conversation tests, 2,650 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -435,6 +436,29 @@ runner without rewriting them. The original public exports remain PolicyInput, PolicyResult, optionIdFor, bareEditorClarificationThread and planEvents. No runtime caller, configuration, paid provider or service activation is added. -Next: bounded original interpreter and policy regression groups, followed by remaining -pure/provider boundaries and runtime integration. Preserve whole callback ownership -and compare complete offline outputs before adding consumers. +The pure interpreter now retains eight outer statements, 25 nonresearch try statements, +original catches, research guard/request await and all seven type/helper declarations. +Only the whole model-checked offer-selection conditional moves into a synchronous +private helper at the same source position. The original await boundaries are unchanged; +member-source narrowing is a wrapper-only type assertion. Both false-guard and eligible +research scheduling regressions pass; temporary extra-await negative controls fail with +zero targeting calls at the observation point instead of one. + +Six additional whole pipeline callbacks are retained, bringing ownership to 15 of 140 +nodes with 125 explicitly deferred. The final original question interpreter outer loop +is preserved once and executes both cases, completing all 14 question callback nodes +and 15 executed cases. The original 123-line D19 fourth-candidate policy suite is +byte-identical under its new filename. Exact fixture helpers are reused where already +retained, and five new archive declarations preserve their bodies unchanged. + +Independent review compares 30 strict complete interpretation outputs, request traces +and replies against the complete original interpreter and original policy: 15 research +offers, five failed interpretations and 12 events. Four dispatch-order controls also +match. These cases use replay-valid histories and injected transport, without stage +seeds; only existing numeric analysis latency is normalized. Input mutations and own +property presence are checked. Research model mismatch/failure remains silent and +records no research analysis pass. No runtime consumer or paid transport is activated. + +Next: effects, conversation processing, durable persistence and Planner integration, +with real integrated regressions before a backend milestone claim. Remaining whole +pipeline callbacks stay explicitly assigned and can be restored in bounded groups. From 004015908f99dc5c6b7fa2d7714974ce69b98b2f Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:30:46 +0100 Subject: [PATCH 035/152] Restore conversation effects and durable Planner job models --- .../src/conversation-plan/effect-fields.ts | 35 +++ .../src/conversation-plan/effect-outbox.ts | 164 +++++++++++ .../conversation-plan/effect-projection.ts | 261 ++++++++++++++++++ .../src/conversation-plan/effect-runner.ts | 110 ++++++++ .../src/conversation-plan/effect-types.ts | 104 +++++++ .../effects-interpretation.test.ts | 90 ++++++ .../conversation-plan/effects-outbox.test.ts | 87 ++++++ .../effects-projection.test.ts | 180 ++++++++++++ .../conversation-plan/effects-retry.test.ts | 99 +++++++ .../conversation-plan/effects-runner.test.ts | 215 +++++++++++++++ .../conversation-plan/effects-scoped.test.ts | 75 +++++ .../effects-settlement.test.ts | 137 +++++++++ .../effects.test-fixtures.ts | 136 +++++++++ apps/server/src/conversation-plan/effects.ts | 16 ++ .../conversation-plan/jobs-restore.test.ts | 104 +++++++ .../conversation-plan/jobs-validation.test.ts | 91 ++++++ .../conversation-plan/jobs.test-fixtures.ts | 5 + .../server/src/conversation-plan/jobs.test.ts | 118 ++++++++ apps/server/src/conversation-plan/jobs.ts | 189 +++++++++++++ .../server/src/questions/card-actions.test.ts | 109 ++++++++ apps/server/src/questions/card-actions.ts | 159 +++++++++++ apps/server/src/questions/card-event.ts | 13 + docs/jev-product-extraction.md | 23 +- 23 files changed, 2517 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/conversation-plan/effect-fields.ts create mode 100644 apps/server/src/conversation-plan/effect-outbox.ts create mode 100644 apps/server/src/conversation-plan/effect-projection.ts create mode 100644 apps/server/src/conversation-plan/effect-runner.ts create mode 100644 apps/server/src/conversation-plan/effect-types.ts create mode 100644 apps/server/src/conversation-plan/effects-interpretation.test.ts create mode 100644 apps/server/src/conversation-plan/effects-outbox.test.ts create mode 100644 apps/server/src/conversation-plan/effects-projection.test.ts create mode 100644 apps/server/src/conversation-plan/effects-retry.test.ts create mode 100644 apps/server/src/conversation-plan/effects-runner.test.ts create mode 100644 apps/server/src/conversation-plan/effects-scoped.test.ts create mode 100644 apps/server/src/conversation-plan/effects-settlement.test.ts create mode 100644 apps/server/src/conversation-plan/effects.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/effects.ts create mode 100644 apps/server/src/conversation-plan/jobs-restore.test.ts create mode 100644 apps/server/src/conversation-plan/jobs-validation.test.ts create mode 100644 apps/server/src/conversation-plan/jobs.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/jobs.test.ts create mode 100644 apps/server/src/conversation-plan/jobs.ts create mode 100644 apps/server/src/questions/card-actions.test.ts create mode 100644 apps/server/src/questions/card-actions.ts create mode 100644 apps/server/src/questions/card-event.ts 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/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..af3a1505 --- /dev/null +++ b/apps/server/src/conversation-plan/jobs.test.ts @@ -0,0 +1,118 @@ +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("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..60ada34f --- /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 oldestDone = jobs.findIndex(job => job.status === "done"); + if (oldestDone < 0) throw new Error("Planner job queue is full"); + available = jobs.filter((_, index) => index !== oldestDone); + } + 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 6097e8a0..d3e63c06 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -205,6 +205,9 @@ and E2E type checks, formatting and CI with the existing warnings listed above. | Ordinary application | The exact whole try/catch retains main application, raw pending deferral, distinct-supporter leaning and agreement follow-ups. Active-local versions, original support caps, uncapped constraint deferral and partial publication after caught failure are covered. | 440 conversation tests, 2,490 assertions | | Complete pure policy | Final fallback, bounded gate and result retain three whole nodes. All seven proposal route conditions, original candidate-loop binding, no-proposal continue and loop-bottom break preserve exact source tests and order. Complete offline public outputs and separately seeded final failures are covered. | 474 conversation tests, 2,606 assertions | | Pure interpreter | The original research guard, request await and catch remain in main. Synchronous offer selection preserves the original model conditional, seven types/helpers, targeting batch order and failure handling. Six whole pipeline callbacks, deferred question loop and original D19 suite are restored. | 485 conversation tests, 2,650 assertions | +| Pure effects | Fourteen exact archived declarations across six acyclic modules preserve projection, strict outbox restoration, recovery and ordered retry delivery. All 25 original callbacks and three whole interpreter/effects pipeline callbacks are retained. | 28 focused tests, 118 assertions | +| Planner job state | The 189-line module is byte-identical; all 22 original declarations and 12 whole callbacks retain deduplication, coalescing, retries, interrupted-job restoration and strict bounds. No Planner delivery is activated. | 12 focused tests, 85 assertions | +| Durable card actions | The 159-line model and complete original test file are retained; the exact CardEvent type moves into a private leaf with a type-only import redirect. Generations, identities, copied choices, UTF-16 truncation and strict queue bounds remain unchanged. | 3 focused tests, 13 assertions | The combined guard/source/quote run passed 30 tests (242 assertions). Domain/replay checks passed 27 tests (109 assertions). The complete extracted foundation and @@ -459,6 +462,20 @@ seeds; only existing numeric analysis latency is normalized. Input mutations and property presence are checked. Research model mismatch/failure remains silent and records no research analysis pass. No runtime consumer or paid transport is activated. -Next: effects, conversation processing, durable persistence and Planner integration, -with real integrated regressions before a backend milestone claim. Remaining whole -pipeline callbacks stay explicitly assigned and can be restored in bounded groups. +The complete pure effects API retains all fourteen original declarations and eleven +fixture declarations once across small modules and suites. Its 25 whole tests and +three interpreter/effects bridge callbacks preserve ordered delivery, per-thread failure +blocking, receipt timing, retryable targets and explicit research omission. Pipeline +ownership is now 18 of 140 whole nodes, with 122 assigned to later groups. + +The Planner job-state module is byte-identical and all twelve original callbacks remain. +The durable card-action model retains its complete source and three-test suite; only a +type-only import points to the unchanged CardEvent alias extracted into a private leaf. +These are complete pure boundaries, with no room, service or Planner consumer activated. + +The combined foundation, effects, jobs and card-action run passes 528 tests with +2,866 assertions. Workspace/E2E types and CI pass with inherited findings only. + +Next: actual conversation processing, durable sidecar/outbox persistence and Planner +delivery, with real integrated regressions before a backend milestone claim. Remaining +whole pipeline callbacks stay explicitly assigned and can be restored in bounded groups. From 3f21533730ead160ed24aa7782ef09094be779ae Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:44:33 +0100 Subject: [PATCH 036/152] Restore conversation processor and durable hosted state --- .../src/conversation-plan/card-mirror.ts | 62 +++++ .../processor-card-context.ts | 33 +++ .../conversation-plan/processor-commands.ts | 174 ++++++++++++++ .../src/conversation-plan/processor-fields.ts | 48 ++++ .../src/conversation-plan/processor-outbox.ts | 99 ++++++++ .../processor-research-admission.ts | 125 ++++++++++ .../processor-research-consent.ts | 90 ++++++++ .../processor-research-quotes.ts | 96 ++++++++ .../src/conversation-plan/processor-run.ts | 192 +++++++++++++++ .../processor-scoped-save.ts | 115 +++++++++ .../src/conversation-plan/processor-types.ts | 43 ++++ .../service-correction.test-fixtures.ts | 141 +++++++++++ .../service-corrections.test.ts | 125 ++++++++++ .../service-effect-commit.test.ts | 158 +++++++++++++ .../service-effect-retry.test.ts | 179 ++++++++++++++ .../service-inference.test.ts | 218 ++++++++++++++++++ .../service-legacy-withdrawal.test.ts | 90 ++++++++ .../service-memory-failure.test.ts | 87 +++++++ .../service-memory.test-fixtures.ts | 112 +++++++++ .../conversation-plan/service-memory.test.ts | 87 +++++++ .../service-mirror-race.test.ts | 159 +++++++++++++ .../conversation-plan/service-order.test.ts | 55 +++++ .../conversation-plan/service-queue.test.ts | 218 ++++++++++++++++++ .../service-scoped-save.test.ts | 98 ++++++++ .../service-scoped-stale.test.ts | 107 +++++++++ .../service-scoped-support.test.ts | 179 ++++++++++++++ .../service-scoped.test-fixtures.ts | 212 +++++++++++++++++ .../service.test-fixtures.ts | 146 ++++++++++++ apps/server/src/conversation-plan/service.ts | 94 ++++++++ .../conversation-analysis-persistence.test.ts | 123 ++++++++++ .../conversation-option-persistence.test.ts | 110 +++++++++ .../conversation-outbox-persistence.test.ts | 113 +++++++++ .../conversation-persistence.test-fixtures.ts | 35 +++ ...conversation-processor-persistence.test.ts | 35 +++ .../src/plan/planner-jobs-failure.test.ts | 68 ++++++ .../src/plan/planner-jobs-restoration.test.ts | 80 +++++++ .../src/plan/planner-jobs.test-fixtures.ts | 23 ++ apps/server/src/plan/planner-jobs.test.ts | 72 ++++++ .../src/plan/question-record-sources.test.ts | 52 ++++- apps/server/src/plan/service.ts | 166 ++++++++++++- docs/jev-product-extraction.md | 26 +++ 41 files changed, 4436 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/conversation-plan/card-mirror.ts create mode 100644 apps/server/src/conversation-plan/processor-card-context.ts create mode 100644 apps/server/src/conversation-plan/processor-commands.ts create mode 100644 apps/server/src/conversation-plan/processor-fields.ts create mode 100644 apps/server/src/conversation-plan/processor-outbox.ts create mode 100644 apps/server/src/conversation-plan/processor-research-admission.ts create mode 100644 apps/server/src/conversation-plan/processor-research-consent.ts create mode 100644 apps/server/src/conversation-plan/processor-research-quotes.ts create mode 100644 apps/server/src/conversation-plan/processor-run.ts create mode 100644 apps/server/src/conversation-plan/processor-scoped-save.ts create mode 100644 apps/server/src/conversation-plan/processor-types.ts create mode 100644 apps/server/src/conversation-plan/service-correction.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/service-corrections.test.ts create mode 100644 apps/server/src/conversation-plan/service-effect-commit.test.ts create mode 100644 apps/server/src/conversation-plan/service-effect-retry.test.ts create mode 100644 apps/server/src/conversation-plan/service-inference.test.ts create mode 100644 apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts create mode 100644 apps/server/src/conversation-plan/service-memory-failure.test.ts create mode 100644 apps/server/src/conversation-plan/service-memory.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/service-memory.test.ts create mode 100644 apps/server/src/conversation-plan/service-mirror-race.test.ts create mode 100644 apps/server/src/conversation-plan/service-order.test.ts create mode 100644 apps/server/src/conversation-plan/service-queue.test.ts create mode 100644 apps/server/src/conversation-plan/service-scoped-save.test.ts create mode 100644 apps/server/src/conversation-plan/service-scoped-stale.test.ts create mode 100644 apps/server/src/conversation-plan/service-scoped-support.test.ts create mode 100644 apps/server/src/conversation-plan/service-scoped.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/service.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/service.ts create mode 100644 apps/server/src/plan/conversation-analysis-persistence.test.ts create mode 100644 apps/server/src/plan/conversation-option-persistence.test.ts create mode 100644 apps/server/src/plan/conversation-outbox-persistence.test.ts create mode 100644 apps/server/src/plan/conversation-persistence.test-fixtures.ts create mode 100644 apps/server/src/plan/conversation-processor-persistence.test.ts create mode 100644 apps/server/src/plan/planner-jobs-failure.test.ts create mode 100644 apps/server/src/plan/planner-jobs-restoration.test.ts create mode 100644 apps/server/src/plan/planner-jobs.test-fixtures.ts create mode 100644 apps/server/src/plan/planner-jobs.test.ts 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/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..8c8cdf76 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-commands.ts @@ -0,0 +1,174 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member, State } from "./processor-types"; +import { applyCorrection, enqueue, restoreState, retryMessage } from "./domain"; +import { effectsFor } from "./effects"; +import { applyEvent } from "./events"; +import { 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: [], + }; + plan.conversationPlan = restoreState({ + ...previous, + revision: previous.revision + 1, + analysis: [...previous.analysis, skipped].slice(-MAX_ANALYSIS), + }, plan.chat.entries); + } + 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; + plan.conversationPlan = next; + try { + let accepted = next.events.at(-1)!; + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor([accepted], next, undefined, plan.records), + ); + 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..fdf74115 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-run.ts @@ -0,0 +1,192 @@ +import { isDeepStrictEqual } from "node:util"; +import type { Dependencies, State } from "./processor-types"; +import { type Interpretation, interpretMessage } from "./interpret"; +import { completeAnalysis } from "./domain"; +import { currentScopedProposal } from "./events"; +import { effectsFor } from "./effects"; +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, + ); + } catch { + next = completeAnalysis( + current, + interpretInput.message.id, + [], + interpretInput.message, + failure("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/service-correction.test-fixtures.ts b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts new file mode 100644 index 00000000..3e4d926c --- /dev/null +++ b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts @@ -0,0 +1,141 @@ +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; +} = {}) { + 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); + 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; + 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..19ea6a47 --- /dev/null +++ b/apps/server/src/conversation-plan/service-corrections.test.ts @@ -0,0 +1,125 @@ +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 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-order.test.ts b/apps/server/src/conversation-plan/service-order.test.ts new file mode 100644 index 00000000..133b0e20 --- /dev/null +++ b/apps/server/src/conversation-plan/service-order.test.ts @@ -0,0 +1,55 @@ +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", + ]); + 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..fbfd5ac4 --- /dev/null +++ b/apps/server/src/conversation-plan/service-queue.test.ts @@ -0,0 +1,218 @@ +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(); + setup.plan.conversationPlan = { + ...initialState(), + queue: Array.from({ length: 128 }, (_, index) => ({ + messageId: `old-${index}`, + status: "failed" as const, + attempts: 0, + })), + }; + 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.analysis.at(-1)).toMatchObject({ + messageId: "new", + status: "unlinked", + policyGate: "queue full; skipped", + }); + expect(setup.durable.state.queue).toHaveLength(128); +}); + +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..a1a19b6a --- /dev/null +++ b/apps/server/src/conversation-plan/service.ts @@ -0,0 +1,94 @@ +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 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(); + } + + return { + accept, + afterMessage, + correct, + saveScopedChoice, + researchConsent, + record, + retry, + setEffects, + wake, + stop, + }; +} 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/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/question-record-sources.test.ts b/apps/server/src/plan/question-record-sources.test.ts index 894736aa..3c33b3fa 100644 --- a/apps/server/src/plan/question-record-sources.test.ts +++ b/apps/server/src/plan/question-record-sources.test.ts @@ -1,5 +1,6 @@ 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, @@ -28,10 +29,57 @@ function sourced() { describe("durable option source restoration", () => { it("checks a member's exact UTF-16 quote and preserves its source through close", async () => { - let { message, record } = sourced(); - let context = await stored([record], [draft()], [message]); + 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); diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index c2105e4d..13f723d2 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -12,11 +12,16 @@ import * as Y from "yjs"; import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; 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 { type Effect, restoreEffectOutbox } from "../conversation-plan/effects"; +import * as Jobs from "../conversation-plan/jobs"; +import { restorePendingCardActions } from "../questions/card-actions"; import * as presence from "./presence"; import * as edit from "./edit"; @@ -30,7 +35,7 @@ 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"; @@ -38,6 +43,7 @@ import type { Block } from "./edit"; import type { Brief, CreationOrigin } from "../mcp"; import type { InitialChannel, JsonValue, Lease, 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"; @@ -142,6 +148,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. * @@ -210,6 +228,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 { @@ -228,6 +252,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 } : {}), }; } @@ -398,6 +436,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 @@ -505,6 +551,51 @@ function restoredState( } } let mcpUpdates = restoreMcpUpdates(item.mcpUpdates); + let conversationPlan = restoreConversationPlan( + item.conversationPlan, + transcript as unknown as Chat.Chat["entries"], + ); + 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, @@ -518,9 +609,39 @@ function restoredState( 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"); @@ -811,6 +932,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. */ @@ -886,13 +1010,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 @@ -925,7 +1057,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, @@ -975,6 +1108,12 @@ 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, Questions.normalizeRecord(record)]), @@ -1007,7 +1146,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 diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index d3e63c06..2a9d616b 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -479,3 +479,29 @@ The combined foundation, effects, jobs and card-action run passes 528 tests with Next: actual conversation processing, durable sidecar/outbox persistence and Planner delivery, with real integrated regressions before a backend milestone claim. Remaining whole pipeline callbacks stay explicitly assigned and can be restored in bounded groups. + +The complete processor now retains its ten-method public API and original asynchronous +boundaries across synchronous private factories. All 33 original callback nodes execute +40 cases. Five real MemoryStorage tests and one capture-order control also pass: message +acceptance, queued work, analysis, pending effects, receipts and retries survive close/open. +A failed receipt commit rolls back live and durable receipt state and replays the same +pending key after reopening. This verifies recovery; effect consumers still own idempotency. +Eight temporary strict comparisons against the original processor match complete state, +durable snapshots, publications, errors and method results with identical clock and ID +entropy. No model provider is called. + +The current Plan service now persists and validates conversation state, retries, Planner +jobs, pending effects, receipts and pending card actions. Interrupted running jobs become +failed and commit before opening returns; observational reads do not write. Existing +canonical question validation and current-main authentication, graph and storage behavior +remain. All twelve selected original Plan persistence callbacks are retained, including +actual processor acceptance followed by reopening. The existing chat-option provenance +fixture now creates accepted events rather than inventing an unbacked chat source. + +Room/chat consumer activation and actual Planner execution remain pending. This checkpoint +alone does not satisfy the requested backend milestone. The next parallel slices connect +Planner delivery and real card consumers, with independent review and integrated recovery +checks before claiming that milestone. + +Combined processor, Plan, conversation and question checks pass 771 tests with 3,738 +assertions. Workspace/E2E types and CI pass with inherited findings only. From d331af79ff306a3bcc5027060ecadca70b699c13 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:01:49 +0100 Subject: [PATCH 037/152] Restore Planner delivery and durable card projection boundaries --- .../agent/heading-harness.test-fixtures.ts | 133 ++++++++++ apps/server/src/agent/heading-harness.test.ts | 127 ++++++++++ .../src/agent/heading-tool.test-bridge.ts | 18 ++ apps/server/src/agent/heading-tool.test.ts | 226 +++++++++++++++++ apps/server/src/agent/heading-tool.ts | 124 ++++++++++ apps/server/src/agent/job-scope.test.ts | 134 ++++++++++ apps/server/src/agent/job-scope.ts | 71 ++++++ apps/server/src/agent/job-tool-context.ts | 18 ++ apps/server/src/agent/job-tools.ts | 66 +++++ .../src/chat/notice-idempotency.test.ts | 87 +++++++ .../src/chat/notice-persistence.test.ts | 140 +++++++++++ .../src/chat/notice-publication.test.ts | 59 +++++ apps/server/src/chat/notice.test-fixtures.ts | 39 +++ apps/server/src/chat/notices.ts | 125 ++++++++++ apps/server/src/chat/service.ts | 17 +- .../src/conversation-plan/job-context.test.ts | 106 ++++++++ .../src/conversation-plan/job-context.ts | 78 ++++++ .../job-prompts-sources.test.ts | 114 +++++++++ .../src/conversation-plan/job-prompts.test.ts | 115 +++++++++ .../src/conversation-plan/job-prompts.ts | 186 ++++++++++++++ .../planner-job-interruption.ts | 47 ++++ .../conversation-plan/planner-job-support.ts | 82 +++++++ .../conversation-plan/planner-job-types.ts | 36 +++ .../planner-jobs-completion.test.ts | 122 +++++++++ .../planner-jobs-failure.test.ts | 114 +++++++++ .../planner-jobs-interruption.test.ts | 133 ++++++++++ .../planner-jobs-memory-failure.test.ts | 88 +++++++ .../planner-jobs-memory.test-fixtures.ts | 142 +++++++++++ .../planner-jobs-memory.test.ts | 78 ++++++ .../planner-jobs-receipt-failure.test.ts | 60 +++++ .../planner-jobs-receipt.test.ts | 97 ++++++++ .../planner-jobs-retry.test.ts | 130 ++++++++++ .../planner-jobs.test-fixtures.ts | 88 +++++++ .../conversation-plan/planner-jobs.test.ts | 125 ++++++++++ .../src/conversation-plan/planner-jobs.ts | 231 ++++++++++++++++++ .../prose-job-anchor.test.ts | 70 ++++++ .../prose-job.test-fixtures.ts | 19 ++ .../src/conversation-plan/prose-job.test.ts | 71 ++++++ .../server/src/conversation-plan/prose-job.ts | 141 +++++++++++ apps/server/src/harness/agents.ts | 41 ++-- apps/server/src/harness/session.ts | 16 +- .../src/plan/card-answer-projection.test.ts | 91 +++++++ .../plan/card-lifecycle-projection.test.ts | 80 ++++++ .../src/plan/card-option-projection.test.ts | 51 ++++ .../plan/card-projections.test-fixtures.ts | 12 + apps/server/src/plan/card-projections.ts | 152 ++++++++++++ .../src/plan/card-prompt-projection.test.ts | 37 +++ apps/server/src/plan/notice-validation.ts | 149 +++++++++++ apps/server/src/plan/room.ts | 40 ++- apps/server/src/plan/service.ts | 69 +++++- .../plan/staged-publication-guards.test.ts | 54 ++++ .../plan/staged-publication.test-fixtures.ts | 25 ++ .../src/plan/staged-publication.test.ts | 73 ++++++ apps/server/src/questions/card-involved.ts | 37 +++ docs/jev-product-extraction.md | 40 +++ packages/dialect/src/dialect.test.ts | 19 ++ packages/dialect/src/dialect.ts | 23 +- packages/dialect/src/index.ts | 3 +- packages/dialect/src/limits.ts | 2 + .../src/nodes/questionnaire-choices.test.ts | 58 +++++ .../dialect/src/nodes/questionnaire-fields.ts | 108 ++++++++ .../src/nodes/questionnaire-history.test.ts | 96 ++++++++ .../src/nodes/questionnaire-lifecycle.test.ts | 55 +++++ .../dialect/src/nodes/questionnaire-mdx.ts | 136 +++++++++++ .../nodes/questionnaire-validation.test.ts | 95 +++++++ .../src/nodes/questionnaire.test-fixtures.ts | 48 ++++ .../dialect/src/nodes/questionnaire.test.ts | 36 +-- packages/dialect/src/nodes/questionnaire.ts | 154 +----------- packages/dialect/src/validate.ts | 119 ++++++++- packages/protocol/chat.d.ts | 27 +- packages/protocol/question.d.ts | 112 ++++++++- 71 files changed, 5683 insertions(+), 232 deletions(-) create mode 100644 apps/server/src/agent/heading-harness.test-fixtures.ts create mode 100644 apps/server/src/agent/heading-harness.test.ts create mode 100644 apps/server/src/agent/heading-tool.test-bridge.ts create mode 100644 apps/server/src/agent/heading-tool.test.ts create mode 100644 apps/server/src/agent/heading-tool.ts create mode 100644 apps/server/src/agent/job-scope.test.ts create mode 100644 apps/server/src/agent/job-scope.ts create mode 100644 apps/server/src/agent/job-tool-context.ts create mode 100644 apps/server/src/agent/job-tools.ts create mode 100644 apps/server/src/chat/notice-idempotency.test.ts create mode 100644 apps/server/src/chat/notice-persistence.test.ts create mode 100644 apps/server/src/chat/notice-publication.test.ts create mode 100644 apps/server/src/chat/notice.test-fixtures.ts create mode 100644 apps/server/src/chat/notices.ts create mode 100644 apps/server/src/conversation-plan/job-context.test.ts create mode 100644 apps/server/src/conversation-plan/job-context.ts create mode 100644 apps/server/src/conversation-plan/job-prompts-sources.test.ts create mode 100644 apps/server/src/conversation-plan/job-prompts.test.ts create mode 100644 apps/server/src/conversation-plan/job-prompts.ts create mode 100644 apps/server/src/conversation-plan/planner-job-interruption.ts create mode 100644 apps/server/src/conversation-plan/planner-job-support.ts create mode 100644 apps/server/src/conversation-plan/planner-job-types.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-completion.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-failure.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-interruption.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-memory.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-receipt.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs-retry.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs.test.ts create mode 100644 apps/server/src/conversation-plan/planner-jobs.ts create mode 100644 apps/server/src/conversation-plan/prose-job-anchor.test.ts create mode 100644 apps/server/src/conversation-plan/prose-job.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/prose-job.test.ts create mode 100644 apps/server/src/conversation-plan/prose-job.ts create mode 100644 apps/server/src/plan/card-answer-projection.test.ts create mode 100644 apps/server/src/plan/card-lifecycle-projection.test.ts create mode 100644 apps/server/src/plan/card-option-projection.test.ts create mode 100644 apps/server/src/plan/card-projections.test-fixtures.ts create mode 100644 apps/server/src/plan/card-projections.ts create mode 100644 apps/server/src/plan/card-prompt-projection.test.ts create mode 100644 apps/server/src/plan/notice-validation.ts create mode 100644 apps/server/src/plan/staged-publication-guards.test.ts create mode 100644 apps/server/src/plan/staged-publication.test-fixtures.ts create mode 100644 apps/server/src/plan/staged-publication.test.ts create mode 100644 apps/server/src/questions/card-involved.ts create mode 100644 packages/dialect/src/nodes/questionnaire-choices.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-fields.ts create mode 100644 packages/dialect/src/nodes/questionnaire-history.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-lifecycle.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-mdx.ts create mode 100644 packages/dialect/src/nodes/questionnaire-validation.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire.test-fixtures.ts 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..e9887c42 --- /dev/null +++ b/apps/server/src/agent/heading-harness.test.ts @@ -0,0 +1,127 @@ +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: "prose", target: "thread" }; + let called = false; + try { + let opened = await openPlannerSession({} as never, { + room: documentRoom(context), + repository: {} as never, + instructions: "Background prose", + }, { + 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 prose 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/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..2e691fd7 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -29,11 +29,13 @@ import * as Service from "../plan/service"; import { instruction } from "@chopin/protocol/address"; import { annotatedText, compose, referenceCatalog, remember } from "./address"; +import { createNotices } from "./notices"; +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"; @@ -103,6 +105,10 @@ export type Instruction = { }; export type Chat = { + job?: ConversationPlan.Job; + jobOutput?: string; + jobFailures?: number; + jobFailureCode?: "source-shape"; entries: Wire.Entry[]; waiting: Waiting[]; /** Serializes complete member send acceptance, including asynchronous resolution and persistence. */ @@ -606,7 +612,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,7 +678,7 @@ 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(); } 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..9192f218 --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; +import { createJobContexts } from "./job-context"; + +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 plan = { chat: { owner: undefined } }; + let original = context("original-session"); + contexts.remember(plan, "m0", original); + for (let index = 1; index <= 80; index++) { + contexts.remember(plan, `m${index}`, context(`other-${index}`)); + } + expect(contexts.claim(plan, job("m0"))).toEqual({ + context: original, + claimantSessionId: "original-session", + }); + expect(contexts.claim(plan, job("missing"))).toBeUndefined(); +}); + +test("a failed accept removes only its own captured context", () => { + let contexts = createJobContexts(); + let plan = { chat: { owner: undefined } }; + let old = context("old"); + let failed = context("failed"); + contexts.remember(plan, "old-message", old); + contexts.remember(plan, "new-message", failed); + contexts.forget(plan, "new-message", failed); + expect(contexts.claim(plan, job("new-message"))).toBeUndefined(); + expect(contexts.claim(plan, job("old-message"))?.context).toBe(old); + let replacement = context("replacement"); + let prior = contexts.remember(plan, "old-message", replacement); + contexts.forget(plan, "old-message", old); + expect(contexts.claim(plan, job("old-message"))?.context).toBe(replacement); + contexts.restore(plan, "old-message", replacement, prior); + expect(contexts.claim(plan, job("old-message"))?.context).toBe(old); +}); + +test("a duplicate-ID accept failure restores the first accepted claimant", async () => { + let contexts = createJobContexts(); + let plan = { chat: { owner: undefined } }; + let accepted = context("accepted-session"); + let duplicate = context("duplicate-session"); + await contexts.accept(plan, "same-message", accepted, async () => {}); + await expect(contexts.accept(plan, "same-message", duplicate, async () => { + throw new Error("duplicate message"); + })).rejects.toThrow("duplicate message"); + expect(contexts.claim(plan, 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 plan = { chat: { owner: { sessionId: "owner" } } }; + let original = context("original"); + contexts.remember(plan, "m1", original); + contexts.remember(plan, "m2", context("later")); + expect(contexts.claim(plan, job("m1"))).toEqual({ + context: original, + claimantSessionId: "owner", + }); + expect(contexts.claim(plan, job("missing"))).toBeUndefined(); + let owner = context("owner"); + contexts.remember(plan, "owner-message", owner); + expect(contexts.claim(plan, 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 plan = { chat: { owner: { sessionId: "owner" } } }; + contexts.remember(plan, "old-message", context("owner")); + let prose: Job = { + ...job("decided:W1:2"), + id: "prose:W1:decided:W1:2", + kind: "prose", + }; + expect(contexts.claim(plan, prose)).toBeUndefined(); + let saver = context("save-session"); + contexts.remember(plan, prose.trigger, saver); + expect(contexts.claim(plan, prose)).toEqual({ + context: saver, + claimantSessionId: "owner", + }); +}); 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..0e494120 --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.ts @@ -0,0 +1,78 @@ +/** + * Process-local Chat contexts for the member message that started a job. + * Keep them until the document closes: an arbitrary size cap can drop the + * original claimant while a durable job waits behind many later messages. + */ + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; + +type Plan = object & { chat: { owner?: { sessionId: string } } }; + +export function createJobContexts() { + let byPlan = new WeakMap>(); + 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.set(messageId, context); + 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); + byMessage.set(messageId, context); + try { + return await commit(); + } catch (error) { + if (byMessage.get(messageId) === context) { + if (previous) byMessage.set(messageId, previous); + else byMessage.delete(messageId); + } + throw error; + } + }, + 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); + }, + any(plan: Plan): Chat.Room | undefined { + return [...(byPlan.get(plan)?.values() ?? [])].at(-1); + }, + claim(plan: Plan, job: Job): { context: Chat.Room; claimantSessionId: string } | undefined { + 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() ?? [])].find(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/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..0443c161 --- /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 a done job 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..92a90190 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts @@ -0,0 +1,97 @@ +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("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..ad3b1519 --- /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") { + 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/prose-job-anchor.test.ts b/apps/server/src/conversation-plan/prose-job-anchor.test.ts new file mode 100644 index 00000000..6f473966 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job-anchor.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { $createTextNode, $getRoot, $isParagraphNode } from "lexical"; + +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 } from "./prose-job"; +import { CARD, OPTION, QUESTION } from "./prose-job.test-fixtures"; + +test("proseInput reads the exact edited prose anchor beside an old identical paragraph", async () => { + let context = await openPlan("Same.\n\nSame.\n"); + try { + let { plan } = context; + plan.records.set( + CARD, + Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Authentication", + question: "Which approach?", + multiple: false, + options: [{ id: OPTION, label: "Chosen option", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "thread-a", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + answers: { [QUESTION]: "Chosen option" }, + prose: [room.anchorAt(plan.document, 1, room.digests(plan.document)[1]!)], + }), + ); + plan.conversationPlan.threads = [{ + id: "thread-a", + questionnaireId: CARD, + question: "Which approach?", + questionAuthoring: "quoted", + status: "decided", + questionSources: [], + contributions: [ + { kind: "reason", text: "Chosen reason", targetId: OPTION, sources: [] }, + { kind: "reason", text: "Unrelated reason", targetId: "other-option", sources: [] }, + ], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 1, + }] as never; + plan.document.editor.update(() => { + let paragraph = $getRoot().getChildren()[1]; + if (!$isParagraphNode(paragraph)) throw new Error("second paragraph is missing"); + paragraph.append($createTextNode(" Edited.")); + }, { discrete: true }); + await room.settle(); + expect(proseInput(plan, CARD)).toMatchObject({ + id: CARD, + chosen: ["Chosen option"], + reasons: ["Chosen reason"], + existing: "Same. Edited.", + }); + } 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..bc1f44a1 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.test.ts @@ -0,0 +1,71 @@ +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."], + existing: "The previous decision used Auth0.", + }); + expect(prompt).toContain("[Background job: prose]"); + expect(prompt).toContain(`Decision card id: ${CARD}`); + expect(prompt).toContain("GitHub Apps"); + expect(prompt).toContain("The previous decision used Auth0."); + 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)), + existing: "E".repeat(10000), + }).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..82689b91 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.ts @@ -0,0 +1,141 @@ +/** A saved conversation decision's durable job identity and bounded writing context. */ + +import { parse } from "@chopin/dialect"; + +import * as room from "../plan/room"; +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[]; + existing?: 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)}`), + ); + } + if (input.existing) { + lines.push( + "", + "The decision was reopened and decided again. Its existing paragraph reads:", + quote(input.existing, 600), + ); + } + lines.push( + "", + "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"); +} + +/** Resolve any prior paragraph by live anchor identity, never by its first matching digest. */ +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) ?? []; + let anchor = record.prose?.length === 1 && !record.prose[0]?.orphaned + ? record.prose[0] + : undefined; + let index = anchor + ? room.digests(plan.document).findIndex((_, at) => + room.matchesAnchor(plan.document, anchor, at) + ) + : -1; + let block = index >= 0 ? parse(room.project(plan.document)).children[index] : undefined; + return { + id, + question: question?.question ?? "", + chosen, + owner: record.owner!, + involved: Questions.involved(plan, record), + reasons, + ...(block?.type === "paragraph" ? { existing: room.blockText(plan.document, [index]) } : {}), + }; +} diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index a14b42d3..f50db1a9 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -4,6 +4,8 @@ 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 { WRITE_TOOLS } from "../agent/job-scope"; import { harnessSelection } from "../config"; import { GITHUB_TOOL_SCHEMAS } from "./github-tools"; import { PLANNER_TOOL_NAMES } from "./tool-names"; @@ -27,7 +29,12 @@ const githubPlaceholders: ToolSet = Object.fromEntries( ]), ); -const plannerTools: ToolSet = { ...documentTools, ...repositories, ...githubPlaceholders }; +const plannerTools: ToolSet = { + ...documentTools, + ...repositories, + ...githubPlaceholders, + ...jobTools, +}; export { PLANNER_TOOL_NAMES } from "./tool-names"; type PlannerCallOptions = { @@ -39,28 +46,34 @@ type PlannerCallOptions = { model?: string; }; -export function createPlannerAgent(harness: HarnessV1) { +export function createPlannerAgent(harness: HarnessV1, profile?: "heading") { + let names = profile === "heading" + ? [...PLANNER_TOOL_NAMES.filter(name => !WRITE_TOOLS.has(name)), "draft_heading"] + : 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"); type WorkerCallOptions = { model: string; diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index a22664a3..b9629065 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -1,5 +1,5 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; -import { plannerAgent } from "./agents"; +import { headingPlannerAgent, plannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; @@ -32,6 +32,7 @@ export type PlannerSession = { export type PlannerSessionDependencies = { agent?: PlannerAgent; + headingAgent?: PlannerAgent; githubTools?: typeof githubTools; createSandbox?: () => Promise; registerCredential?: typeof registerCredential; @@ -43,6 +44,16 @@ export async function openPlannerSession( channel: PlannerChannel, deps: PlannerSessionDependencies = {}, ): Promise> { + let job = channel.room.plan.chat?.job; + if (job && job.kind !== "heading") { + 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 +84,8 @@ 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 ? deps.headingAgent ?? headingPlannerAgent : plannerAgent); let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); let deadline = new Promise((_, reject) => { timeout = setTimeout( 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/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/room.ts b/apps/server/src/plan/room.ts index 12929416..5134bd2d 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -33,6 +33,9 @@ import { } from "@chopin/dialect"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; import * as Questionnaires from "./questionnaires"; +import { createCardProjections } from "./card-projections"; +export { QuestionnaireProjectionError } from "./card-projections"; +export type { CardChange } from "./card-projections"; import { $createParagraphNode, $getNodeByKey, @@ -472,31 +475,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); /** Take a questionnaire out of the plan, leaving its record as history. */ export function removeQuestionnaire(target: Document, id: string): Mutation | undefined { @@ -602,6 +582,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(); diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 13f723d2..0ba260c7 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -22,6 +22,7 @@ import { restoreState as restoreConversationPlan } from "../conversation-plan/do 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"; @@ -528,6 +529,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") { @@ -555,6 +557,7 @@ function restoredState( 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 ( @@ -751,6 +754,7 @@ async function commitHosted( operationId: string, captured: Captured, allowArchived = false, + notifyDocumentPersisted = true, ): Promise { let durable = plan.persistence; if (!update && captured.sidecarText === durable.lastSidecar) { @@ -798,7 +802,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, @@ -1371,6 +1375,69 @@ 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, 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 2a9d616b..c5522ea3 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -505,3 +505,43 @@ checks before claiming that milestone. Combined processor, Plan, conversation and question checks pass 771 tests with 3,738 assertions. Workspace/E2E types and CI pass with inherited findings only. + +The complete Planner delivery coordinator, context selection, prompts and prose-intent +helpers retain all 47 original callbacks. Four real MemoryStorage integrations connect +the actual processor and interpreter to durable enqueueing. Receipts and running state +commit before the scripted runner executes; completion and activity persist together. +Failed enqueueing retains the pending effect, explicit retry preserves job identity, and +stopped running work becomes interrupted on reopening without automatic replay. + +Card projection prerequisites now preserve thread/status, chosen IDs and previous-choice +history through the restricted dialect and Lexical serialization. All 25 original node +callbacks and twelve original room projection callbacks remain. Protocol additions retain +current consumers and the existing cancellation API. A broad schema rewrite was rejected +by automatic approval review; bounded patches were approved after proving unrelated +validation rules unchanged. No rejected source write executed. + +Staged publication commits the candidate document and sidecar before adopting or +broadcasting their state. Four new storage tests cover held and rejected commits, +reopening, the implementation guard and default-preserving notification suppression. +This evidence covers document and receipt isolation; the fixture does not change records +or drafts. Ten whole original notice tests cover rollback, ordering, stable IDs and +reopening. Notice metadata validation and accepted scoped-evidence checks are retained. +Ordinary instruction notices preserve the current persistence callback and queue behavior. + +The original heading and job-scope functions retain all fourteen original callbacks. +Three new tests execute the actual heading tool through the current Harness and storage, +verify the live write boundary, and reject unsupported job kinds. The installed Harness +requires constructor-specific active tools, so the heading profile offers read tools and +its own writer. Ordinary Planner tool names remain unchanged; Copilot and Pi adapters, +ownership, credentials and disposable-session cleanup retain their current lifecycle. +A temporary negative control confirms that disabling the live refusal makes the foreign +write test fail. No paid inference is used. + +The combined affected backend, Harness, dialect and question run passes 1,267 tests with +5,623 assertions. Workspace/E2E types and CI pass with inherited findings only. The Pi +contract uses its existing disposable loopback stub, with nine passing contract cases. + +Next: actual Chat job FIFO and saved-message hook integration, complete card effect +consumers and remaining scoped job tools. These components are now verified separately, +but live conversation-to-Planner wiring is still required for the requested backend +milestone. Remaining whole pipeline callbacks stay assigned to their implementation groups. diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 80bd73f6..196a4fbc 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -309,3 +309,22 @@ describe("diagnostics", () => { expect(JSON.stringify(result.issues)).not.toContain(secret); }); }); + +// Exact pending-card callback from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +it("accepts a pending conversation question with no options", () => { + let source = `\n` + + `\n` + + ``; + accepts(source); + expect(canonical(source)).toContain("Which system?"); + accepts(source.replace('status="open"', 'status="discarded"')); + expect(codes(source.replace('thread="thread-a" ', ""))).toContain("missing-children"); + expect( + codes( + source.replace( + "", + `\n`, + ), + ), + ).toContain("missing-children"); +}); diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 83c62c14..6116483e 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -99,6 +99,12 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Question"] }, forbids: ["Questionnaire", "Tabs", "Callout"], attributes: { + thread: { type: "text", required: false, max: limits.MAX_ID }, + status: { + type: "enum", + required: false, + values: ["open", "decided", "reopened", "discarded"], + }, by: { type: "text", required: false, max: limits.MAX_HANDLE }, at: { type: "text", required: false, max: limits.MAX_TIMESTAMP }, }, @@ -107,7 +113,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ Question: component({ name: "Question", kind: "flow", - content: { type: "components", names: ["Option", "Answer"] }, + content: { type: "components", names: ["Option", "Answer", "Previous"] }, parent: ["Questionnaire"], attributes: { header: { type: "text", required: true, max: limits.MAX_QUESTION_HEADER }, @@ -138,6 +144,21 @@ export const COMPONENTS: Readonly> = Object.freeze({ parent: ["Question"], attributes: { value: { type: "text", required: true, max: limits.MAX_CUSTOM_ANSWER }, + choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, + }, + }), + + /** Latest decision replaced by a reopened question; the record holds the full history. */ + Previous: plain({ + name: "Previous", + kind: "flow", + content: { type: "empty" }, + parent: ["Question"], + attributes: { + choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, + value: { type: "text", required: false, max: limits.MAX_CUSTOM_ANSWER }, + by: { type: "text", required: true, max: limits.MAX_HANDLE }, + at: { type: "text", required: true, max: limits.MAX_TIMESTAMP }, }, }), diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index 721b9dfc..d9a87ef5 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -73,9 +73,10 @@ export type { SerializedResearch } from "./nodes/research"; export { $createQuestionnaireNode, $isQuestionnaireNode, + cardStatus, QuestionnaireNode, } from "./nodes/questionnaire"; -export type { Option, Question, Questionnaire } from "./nodes/questionnaire"; +export type { CardStatus, Option, Previous, Question, Questionnaire } from "./nodes/questionnaire"; export { $createDecisionNode, $isDecisionNode, DecisionNode } from "./nodes/decision"; export type { Decision, Note } from "./nodes/decision"; diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index ab30a8e9..5d03d763 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -37,6 +37,8 @@ export const MAX_QUESTION_PROMPT = 1_000; export const MAX_OPTION_LABEL = 200; export const MAX_OPTION_DESCRIPTION = 1_000; export const MAX_CUSTOM_ANSWER = 4_000; +/** Up to twenty ULIDs with separators in one answer. */ +export const MAX_ANSWER_CHOICES = 600; /** * Accepted comment threads, projected into the plan as ``. diff --git a/packages/dialect/src/nodes/questionnaire-choices.test.ts b/packages/dialect/src/nodes/questionnaire-choices.test.ts new file mode 100644 index 00000000..7773315a --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-choices.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "bun:test"; + +import * as limits from "../limits"; +import { ulid } from "../ulid"; + +import { BLUE, CANARY, ID, OPEN, QUESTION, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips chosen option identifiers", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(` { + let source = OPEN.replace('multiple="false"', 'multiple="true"').replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(`choices="${CANARY} ${BLUE}"`); +}); + +it("keeps older and custom answers without chosen identifiers", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(' { + let answer = (choices: string) => + OPEN.replace("", `\n`); + expect(() => through(answer(ID))).toThrow(/Answer choices must name options/); + expect(() => through(answer(`${CANARY} ${CANARY}`))).toThrow(/cannot repeat/); + expect(() => through(answer(`${CANARY} ${BLUE}`))).toThrow(/accepts one choice/); +}); + +it("rejects more chosen identifiers than the legacy option ceiling", () => { + let ids = Array.from({ length: limits.MAX_OPTIONS + 1 }, () => ulid()); + let source = (selected: string[]) => + `\n` + + `\n` + + ids.map(id => `\n\n`; + expect(through(source(ids.slice(0, limits.MAX_OPTIONS)))).toContain(" = new Set(["open", "decided", "reopened", "discarded"]); + +export function cardStatus(value: Questionnaire): CardStatus { + if (value.status !== undefined) { + if (!STATUSES.has(value.status)) throw new Error("invalid questionnaire status"); + return value.status; + } + return value.questions.length > 0 + && value.questions.every(question => question.answer !== undefined) + ? "decided" + : "open"; +} + +export function parse(value: unknown): Questionnaire { + if (!value || typeof value !== "object") return EMPTY; + let raw = value as Partial; + if ( + Object.hasOwn(raw, "thread") + && (typeof raw.thread !== "string" || !raw.thread.trim() || raw.thread.length > limits.MAX_ID) + ) throw new Error("invalid questionnaire thread"); + if (Object.hasOwn(raw, "status") && !STATUSES.has(raw.status as CardStatus)) { + throw new Error("invalid questionnaire status"); + } + let questions = Array.isArray(raw.questions) ? raw.questions : []; + for (let question of questions) { + if (!question || typeof question !== "object" || !Object.hasOwn(question, "previous")) { + continue; + } + let previous = question.previous; + if ( + !previous || typeof previous !== "object" || Array.isArray(previous) + || !Array.isArray(previous.choices) + || previous.choices.length > limits.MAX_OPTIONS + || (previous.choices.length === 0) === (previous.value === undefined) + || (Object.hasOwn(previous, "value") + && (typeof previous.value !== "string" || !previous.value.trim() + || previous.value.length > limits.MAX_CUSTOM_ANSWER)) + || typeof previous.by !== "string" || !previous.by.trim() + || previous.by.length > limits.MAX_HANDLE + || typeof previous.at !== "string" || !previous.at.trim() + || previous.at.length > limits.MAX_TIMESTAMP + || !Array.isArray(question.options) + ) throw new Error("invalid questionnaire previous decision"); + let options = new Set(question.options.map(option => option?.id)); + if ( + previous.choices.some(id => typeof id !== "string" || !options.has(id)) + || new Set(previous.choices).size !== previous.choices.length + || (!question.multiple && previous.choices.length > 1) + ) throw new Error("invalid questionnaire previous choices"); + } + return { + id: typeof raw.id === "string" ? raw.id : "", + questions, + ...(raw.thread === undefined ? {} : { thread: raw.thread }), + ...(raw.status === undefined ? {} : { status: raw.status }), + ...(typeof raw.by === "string" ? { by: raw.by } : {}), + ...(typeof raw.at === "string" ? { at: raw.at } : {}), + }; +} diff --git a/packages/dialect/src/nodes/questionnaire-history.test.ts b/packages/dialect/src/nodes/questionnaire-history.test.ts new file mode 100644 index 00000000..b3884dad --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-history.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import { exportPlan, importPlan } from "../convert"; +import * as limits from "../limits"; +import { ulid } from "../ulid"; +import { $isQuestionnaireNode } from "./questionnaire"; +import { + BLUE, + CANARY, + editor, + ID, + OPEN, + QUESTION, + REGISTRY, + through, +} from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips a previous decision through MDX and Lexical JSON", () => { + let source = OPEN + .replace( + ``, + ``, + ) + .replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain( + ``, + ); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + let json = JSON.stringify(instance.getEditorState()); + let restored = editor(); + restored.setEditorState(restored.parseEditorState(json)); + expect(exportPlan(restored, { registry: REGISTRY })).toBe(out); + restored.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.getQuestionnaire().questions[0]!.previous).toEqual({ + choices: [BLUE], + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }); + }); +}); + +it("round-trips a previous answer without inventing an option choice", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(out).toContain(' { + let previous = (attributes: string) => + OPEN.replace( + "", + `\n`, + ); + expect(() => through(previous(""))).toThrow(); + expect(() => through(previous(`choices="${BLUE}" value="Text"`))).toThrow(); + expect(() => through(previous('value=""'))).toThrow(); + expect(() => through(previous(`value="${"x".repeat(4001)}"`))).toThrow(); +}); + +it("rejects unknown, repeated, and excess previous choice identifiers", () => { + let previous = (choices: string) => + OPEN.replace( + "", + `\n`, + ); + expect(() => through(previous(`${BLUE} nope`))).toThrow(/Previous choices must name options/); + expect(() => through(previous(`${BLUE} ${BLUE}`))).toThrow(/Previous choices cannot repeat/); + expect(() => through(previous(`${CANARY} ${BLUE}`))).toThrow(/accepts one choice/); + let ids = Array.from({ length: limits.MAX_OPTIONS + 1 }, () => ulid()); + let source = `\n` + + `\n` + + ids.map(id => `\n\n`; + expect(() => through(source)).toThrow(/Previous accepts at most 20 choices/); +}); + +it("rejects multiple previous decisions in one question", () => { + let previous = ``; + let source = OPEN.replace("", `${previous}\n${previous}\n`); + expect(() => through(source)).toThrow(/at most one Previous/); +}); diff --git a/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts b/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts new file mode 100644 index 00000000..bc664238 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import { importPlan } from "../convert"; + +import { $isQuestionnaireNode, cardStatus, fromElement } from "./questionnaire"; +import { editor, ID, OPEN, parsed, REGISTRY, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips thread and status through MDX and Lexical", () => { + let source = OPEN.replace( + ``, + ``, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain('thread="thread:abc" status="discarded"'); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + instance.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.getQuestionnaire()).toMatchObject({ + thread: "thread:abc", + status: "discarded", + }); + expect(cardStatus(node.getQuestionnaire())).toBe("discarded"); + }); +}); + +it("does not let a discarded hidden node receive keyboard selection", () => { + let source = OPEN.replace( + ``, + ``, + ); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + instance.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.isKeyboardSelectable()).toBe(false); + }); +}); + +it("derives status only when older documents omit it", () => { + let open = fromElement(parsed(OPEN)); + expect(open).not.toHaveProperty("status"); + expect(open).not.toHaveProperty("thread"); + expect(cardStatus(open)).toBe("open"); + let answered = OPEN.replace("", `\n`); + expect(cardStatus(fromElement(parsed(answered)))).toBe("decided"); + for (let status of ["open", "decided", "reopened", "discarded"] as const) { + expect(cardStatus({ ...open, status })).toBe(status); + } + expect(() => cardStatus({ ...open, status: "maybe" as "open" })).toThrow(/status/); +}); diff --git a/packages/dialect/src/nodes/questionnaire-mdx.ts b/packages/dialect/src/nodes/questionnaire-mdx.ts new file mode 100644 index 00000000..43d44720 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-mdx.ts @@ -0,0 +1,136 @@ +import { attribute, attributes, identity } from "./shared"; +import type { Jsx } from "./shared"; +import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; +import { + type CardStatus, + type Option, + parse, + type Question, + type Questionnaire, +} from "./questionnaire-fields"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 node declarations; import/export wrappers only. + +function elements(node: Jsx, name: string): MdxJsxFlowElement[] { + let out: MdxJsxFlowElement[] = []; + for (let child of node.children) { + if (child.type === "mdxJsxFlowElement" && child.name === name) out.push(child); + } + return out; +} + +export function fromElement(node: Jsx): Questionnaire { + let questions: Question[] = []; + + for (let element of elements(node, "Question")) { + let options: Option[] = []; + for (let source of elements(element, "Option")) { + let option: Option = { + id: attribute(source, "id") ?? "", + label: attribute(source, "label") ?? "", + }; + let description = attribute(source, "description"); + if (description) option.description = description; + options.push(option); + } + + let question: Question = { + id: attribute(element, "id") ?? "", + header: attribute(element, "header") ?? "", + prompt: attribute(element, "prompt") ?? "", + multiple: attribute(element, "multiple") === "true", + options, + }; + + let answer = elements(element, "Answer")[0]; + if (answer) { + question.answer = attribute(answer, "value") ?? ""; + let chosen = (attribute(answer, "choices") ?? "").split(/\s+/).filter(Boolean); + if (chosen.length) question.choices = chosen; + } + + let previousElements = elements(element, "Previous"); + if (previousElements.length > 1) throw new Error("Question accepts at most one Previous"); + let previous = previousElements[0]; + if (previous) { + question.previous = { + choices: (attribute(previous, "choices") ?? "").trim().split(/\s+/).filter(Boolean), + ...(attribute(previous, "value") === undefined + ? {} + : { value: attribute(previous, "value") }), + by: attribute(previous, "by") ?? "", + at: attribute(previous, "at") ?? "", + }; + } + + questions.push(question); + } + + let by = attribute(node, "by"); + let at = attribute(node, "at"); + let thread = attribute(node, "thread"); + let status = attribute(node, "status") as CardStatus | undefined; + + return parse({ + id: attribute(node, "id") ?? "", + questions, + ...(thread === undefined ? {} : { thread }), + ...(status === undefined ? {} : { status }), + ...(by ? { by } : {}), + ...(at ? { at } : {}), + }); +} + +export function toElement(value: Questionnaire): MdxJsxFlowElement { + value = parse(value); + return { + type: "mdxJsxFlowElement", + name: "Questionnaire", + attributes: identity(value.id, { + thread: value.thread, + status: value.status, + by: value.by, + at: value.at, + }), + children: value.questions.map(question => ({ + type: "mdxJsxFlowElement", + name: "Question", + attributes: identity(question.id, { + header: question.header, + prompt: question.prompt, + multiple: String(question.multiple), + }), + children: [ + ...question.options.map(option => ({ + type: "mdxJsxFlowElement" as const, + name: "Option", + attributes: identity(option.id, { label: option.label, description: option.description }), + children: [], + })), + // A projection of sidecar state: addressed through its Question, + // so it carries no identity of its own. + ...(question.answer === undefined ? [] : [{ + type: "mdxJsxFlowElement" as const, + name: "Answer", + attributes: attributes({ value: question.answer, choices: question.choices?.join(" ") }), + children: [], + }]), + ...(question.previous + ? [{ + type: "mdxJsxFlowElement" as const, + name: "Previous", + attributes: attributes({ + choices: question.previous.choices.length + ? question.previous.choices.join(" ") + : undefined, + value: question.previous.value, + by: question.previous.by, + at: question.previous.at, + }), + children: [], + }] + : []), + ], + })), + }; +} diff --git a/packages/dialect/src/nodes/questionnaire-validation.test.ts b/packages/dialect/src/nodes/questionnaire-validation.test.ts new file mode 100644 index 00000000..8b5b85b2 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-validation.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "bun:test"; + +import { importPlan } from "../convert"; + +import { fromElement, toElement } from "./questionnaire"; +import { BLUE, editor, ID, OPEN, parsed, REGISTRY, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("rejects malformed lifecycle attributes in MDX", () => { + let header = ``; + expect(() => through(OPEN.replace(header, ``))) + .toThrow(/status.*must be one of/); + expect(() => through(OPEN.replace(header, ``))) + .toThrow(/thread.*cannot be empty/); + expect(() => + through(OPEN.replace( + header, + ``, + )) + ).toThrow(/thread.*exceeds 200 characters/); + let previous = ``; + expect(() => + through( + OPEN.replace("", `${previous.replace(`choices="${BLUE}"`, "")}\n`), + ) + ) + .toThrow(/Previous.*requires.*choices/); + expect(() => + through(OPEN.replace("", `${previous.replace('by="ana"', 'by=""')}\n`)) + ) + .toThrow(/Previous.by.*cannot be empty/); +}); + +it("accepts a 200-character thread ID and rejects 201 characters in MDX and Lexical", () => { + let header = ``; + let accepted = "a".repeat(200); + let rejected = "a".repeat(201); + let source = OPEN.replace(header, ``); + expect(through(source)).toContain(`thread="${accepted}"`); + let instance = editor(); + importPlan(instance, OPEN, { registry: REGISTRY }); + let state = JSON.parse(JSON.stringify(instance.getEditorState())) as { + root: { children: { planQuestionnaire: Record }[] }; + }; + let value = state.root.children[0]!.planQuestionnaire; + value.thread = accepted; + expect(() => instance.parseEditorState(JSON.stringify(state))).not.toThrow(); + value.thread = rejected; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/thread/); +}); + +it("rejects malformed present lifecycle fields in Lexical JSON", () => { + let instance = editor(); + importPlan(instance, OPEN, { registry: REGISTRY }); + let state = JSON.parse(JSON.stringify(instance.getEditorState())) as { + root: { children: { planQuestionnaire: Record }[] }; + }; + let value = state.root.children[0]!.planQuestionnaire; + value.thread = 42; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/thread/); + delete value.thread; + value.status = "maybe"; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/status/); + delete value.status; + let questions = value.questions as Record[]; + questions[0]!.previous = { choices: [BLUE, "nope"], by: "ana", at: "2026-09-23" }; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/previous/i); +}); + +it("rejects malformed present fields at the plain-data conversion boundary", () => { + let invalidStatus = OPEN.replace( + ``, + ``, + ); + expect(() => fromElement(parsed(invalidStatus))).toThrow(/status/); + let emptyThread = OPEN.replace( + ``, + ``, + ); + expect(() => fromElement(parsed(emptyThread))).toThrow(/thread/); + let invalidPrevious = OPEN.replace( + "", + `\n`, + ); + expect(() => fromElement(parsed(invalidPrevious))).toThrow(/previous/i); + let previous = ``; + expect(() => + fromElement(parsed( + OPEN.replace("", `${previous}\n${previous}\n`), + )) + ).toThrow(/at most one Previous/); + let value = fromElement(parsed(OPEN)); + expect(() => toElement({ ...value, thread: "" })).toThrow(/thread/); + expect(() => toElement({ ...value, status: "" as "open" })).toThrow(/status/); +}); diff --git a/packages/dialect/src/nodes/questionnaire.test-fixtures.ts b/packages/dialect/src/nodes/questionnaire.test-fixtures.ts new file mode 100644 index 00000000..a32b83b8 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire.test-fixtures.ts @@ -0,0 +1,48 @@ +import { createHeadlessEditor } from "@lexical/headless"; +import { exportPlan, importPlan } from "../convert"; +import { parse } from "../parse"; +import { registry } from "../registry"; +import type { LexicalEditor } from "lexical"; +import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, nodes/questionnaire.test.ts helpers/data. +export const REGISTRY = registry(); + +export const ID = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + +export const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; + +export const CANARY = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export const BLUE = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; + +export function editor(): LexicalEditor { + return createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError(err) { + throw err; + }, + }); +} + +export function through(source: string): string { + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + return exportPlan(instance, { registry: REGISTRY }); +} + +export function parsed(source: string): MdxJsxFlowElement { + let node = parse(source).children[0]; + if (node?.type !== "mdxJsxFlowElement" || node.name !== "Questionnaire") { + throw new Error("expected questionnaire element"); + } + return node; +} + +export const OPEN = `\n` + + `\n` + + `\n` + + `\n`; diff --git a/packages/dialect/src/nodes/questionnaire.test.ts b/packages/dialect/src/nodes/questionnaire.test.ts index fa391a97..e865a643 100644 --- a/packages/dialect/src/nodes/questionnaire.test.ts +++ b/packages/dialect/src/nodes/questionnaire.test.ts @@ -1,42 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { createHeadlessEditor } from "@lexical/headless"; import { $getRoot, $isElementNode } from "lexical"; -import { exportPlan, importPlan } from "../convert"; -import { registry } from "../registry"; +import { importPlan } from "../convert"; import { $isQuestionnaireNode } from "./questionnaire"; -import type { LexicalEditor } from "lexical"; - -const REGISTRY = registry(); - -const ID = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; -const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; -const CANARY = "01K0N4W3B7P27CBAEC7A8C8WEA"; -const BLUE = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; - -function editor(): LexicalEditor { - return createHeadlessEditor({ - nodes: REGISTRY.nodes, - onError(err) { - throw err; - }, - }); -} - -function through(source: string): string { - let instance = editor(); - importPlan(instance, source, { registry: REGISTRY }); - return exportPlan(instance, { registry: REGISTRY }); -} - -const OPEN = `\n` - + `\n` - + `\n` - + `\n`; +import { editor, ID, OPEN, REGISTRY, through } from "./questionnaire.test-fixtures"; describe("questionnaire", () => { it("round-trips an open questionnaire", () => { diff --git a/packages/dialect/src/nodes/questionnaire.ts b/packages/dialect/src/nodes/questionnaire.ts index 677c3079..01e0c6d9 100644 --- a/packages/dialect/src/nodes/questionnaire.ts +++ b/packages/dialect/src/nodes/questionnaire.ts @@ -1,11 +1,11 @@ /** * Durable questionnaires. * - * Unlike the other containers this is atomic. A questionnaire's definition is - * immutable once created, and its answer is owned by the sidecar record rather - * than by the document, so there is nothing inside it for two people to edit - * concurrently. Modelling it as a decorator keeps it selectable, movable and - * deletable as one unit while making its contents unwritable by construction. + * Unlike the other containers this is atomic. People may add options to an open + * questionnaire through its sidecar record; its answer also belongs to that + * record. The document carries only the projection. Modelling it as a decorator + * keeps it selectable, movable and deletable as one unit while making its + * contents unwritable by construction. * * The `` written into source is a projection for readability. The * sidecar stays authoritative; a mismatch is a server-side error, not something @@ -13,10 +13,8 @@ */ import { $applyNodeReplacement, $getState, $setState, createState, DecoratorNode } from "lexical"; - import { render } from "./render"; -import { attribute, attributes, identity, isFlow, PRIORITY } from "./shared"; - +import { isFlow, PRIORITY } from "./shared"; import type { LexicalExportVisitor, MdastImportVisitor } from "@mdxeditor/editor"; import type { ElementNode, @@ -26,51 +24,13 @@ import type { Spread, } from "lexical"; import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; -import type { Jsx } from "./shared"; - -export type Option = { - id: string; - label: string; - description?: string; -}; - -export type Question = { - id: string; - header: string; - prompt: string; - multiple: boolean; - options: Option[]; - /** Projection of the resolved answer, when there is one. */ - answer?: string; -}; - -export type Questionnaire = { - id: string; - questions: Question[]; - /** - * Who settled it, and when. - * - * On the questionnaire rather than on each answer: it resolves as a unit, - * so this is one fact about one moment. Absent until it is answered, and - * absent for good on one answered before this was written down. - */ - by?: string; - /** ISO 8601. */ - at?: string; -}; +import { cardStatus, EMPTY, parse, type Questionnaire } from "./questionnaire-fields"; +import { fromElement, toElement } from "./questionnaire-mdx"; +export { cardStatus } from "./questionnaire-fields"; +export type { CardStatus, Option, Previous, Question, Questionnaire } from "./questionnaire-fields"; +export { fromElement, toElement } from "./questionnaire-mdx"; -const EMPTY: Questionnaire = { id: "", questions: [] }; - -function parse(value: unknown): Questionnaire { - if (!value || typeof value !== "object") return EMPTY; - let raw = value as Partial; - return { - id: typeof raw.id === "string" ? raw.id : "", - questions: Array.isArray(raw.questions) ? raw.questions : [], - ...(typeof raw.by === "string" ? { by: raw.by } : {}), - ...(typeof raw.at === "string" ? { at: raw.at } : {}), - }; -} +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 node declarations; import/export wrappers only. export const questionnaireState = createState("plan-questionnaire", { parse, @@ -131,7 +91,7 @@ export class QuestionnaireNode extends DecoratorNode { } override isKeyboardSelectable(): boolean { - return true; + return cardStatus(this.getQuestionnaire()) !== "discarded"; } /** `@chopin/editor` renders the interactive card. */ @@ -150,94 +110,6 @@ export function $isQuestionnaireNode( return node instanceof QuestionnaireNode; } -// -- MDX conversion -------------------------------------------------------- - -function elements(node: Jsx, name: string): MdxJsxFlowElement[] { - let out: MdxJsxFlowElement[] = []; - for (let child of node.children) { - if (child.type === "mdxJsxFlowElement" && child.name === name) out.push(child); - } - return out; -} - -/** Read a validated `` element into plain data. */ -export function fromElement(node: Jsx): Questionnaire { - let questions: Question[] = []; - - for (let element of elements(node, "Question")) { - let options: Option[] = []; - for (let source of elements(element, "Option")) { - let option: Option = { - id: attribute(source, "id") ?? "", - label: attribute(source, "label") ?? "", - }; - let description = attribute(source, "description"); - if (description) option.description = description; - options.push(option); - } - - let question: Question = { - id: attribute(element, "id") ?? "", - header: attribute(element, "header") ?? "", - prompt: attribute(element, "prompt") ?? "", - multiple: attribute(element, "multiple") === "true", - options, - }; - - let answer = elements(element, "Answer")[0]; - if (answer) question.answer = attribute(answer, "value") ?? ""; - - questions.push(question); - } - - let by = attribute(node, "by"); - let at = attribute(node, "at"); - - return { - id: attribute(node, "id") ?? "", - questions, - ...(by ? { by } : {}), - ...(at ? { at } : {}), - }; -} - -/** Write plain data back out as a `` element. */ -export function toElement(value: Questionnaire): MdxJsxFlowElement { - return { - type: "mdxJsxFlowElement", - name: "Questionnaire", - attributes: identity(value.id, { by: value.by, at: value.at }), - children: value.questions.map(question => ({ - type: "mdxJsxFlowElement", - name: "Question", - attributes: identity(question.id, { - header: question.header, - prompt: question.prompt, - multiple: String(question.multiple), - }), - children: [ - ...question.options.map(option => ({ - type: "mdxJsxFlowElement" as const, - name: "Option", - attributes: identity(option.id, { label: option.label, description: option.description }), - children: [], - })), - // A projection of sidecar state: addressed through its Question, - // so it carries no identity of its own. - ...(question.answer === undefined ? [] : [{ - type: "mdxJsxFlowElement" as const, - name: "Answer", - attributes: attributes({ value: question.answer }), - children: [], - }]), - ], - })), - }; -} - -// -- visitors -------------------------------------------------------------- - -/** Import the whole subtree as one atomic node; nothing inside is editable. */ export const MdastQuestionnaireVisitor: MdastImportVisitor = { testNode: isFlow("Questionnaire"), visitNode({ mdastNode, lexicalParent }) { diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 7f6857e4..236fd637 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -41,6 +41,15 @@ function isJsx(node: Nodes): node is Jsx { return node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement"; } +function stringAttribute(node: Jsx, name: string): string | undefined { + let found = node.attributes.find(attribute => + attribute.type === "mdxJsxAttribute" && attribute.name === name + ); + return found?.type === "mdxJsxAttribute" && typeof found.value === "string" + ? found.value + : undefined; +} + function children(node: Nodes): Nodes[] { return (node as Parent).children ?? []; } @@ -82,7 +91,7 @@ class Validator { } } - #walk(node: Nodes, path: string, depth: number, ancestors: string[]): void { + #walk(node: Nodes, path: string, depth: number, ancestors: string[], parent?: Jsx): void { if (depth > limits.MAX_DEPTH) { this.add("too-deep", `Content nests deeper than ${limits.MAX_DEPTH} levels`, path, node); return; @@ -99,7 +108,7 @@ class Validator { } if (isJsx(node)) { - this.#component(node, path, depth, ancestors); + this.#component(node, path, depth, ancestors, parent); return; } @@ -131,11 +140,17 @@ class Validator { let name = label(child); let index = counts.get(name) ?? 0; counts.set(name, index + 1); - this.#walk(child, `${path} > ${name}[${index}]`, depth + 1, ancestors); + this.#walk( + child, + `${path} > ${name}[${index}]`, + depth + 1, + ancestors, + isJsx(node) ? node : undefined, + ); } } - #component(node: Jsx, path: string, depth: number, ancestors: string[]): void { + #component(node: Jsx, path: string, depth: number, ancestors: string[], parentNode?: Jsx): void { if (!node.name) { this.add("fragment", "JSX fragments are not allowed in plans", path, node); return; @@ -181,11 +196,93 @@ class Validator { } this.#attributes(node, spec, path); - this.#content(node, spec, path); + this.#content(node, spec, path, parentNode); + if (spec.name === "Question") this.#questionChoices(node, path); this.#descend(node, path, depth, [...ancestors, spec.name]); } + #questionChoices(node: Jsx, path: string): void { + let elements = children(node).filter(isJsx); + let options = new Set( + elements.filter(item => item.name === "Option") + .map(item => stringAttribute(item, "id")), + ); + for (let answer of elements.filter(item => item.name === "Answer")) { + let raw = stringAttribute(answer, "choices"); + if (raw === undefined) continue; + let chosen = raw.trim().split(/\s+/).filter(Boolean); + if (chosen.length > limits.MAX_OPTIONS) { + this.add( + "too-many-answer-choices", + `Answer accepts at most ${limits.MAX_OPTIONS} choices`, + path, + answer, + ); + } + if (chosen.some(id => !options.has(id))) { + this.add( + "unknown-answer-choice", + "Answer choices must name options in this question", + path, + answer, + ); + } + if (new Set(chosen).size !== chosen.length) { + this.add("duplicate-answer-choice", "Answer choices cannot repeat an option", path, answer); + } + if (stringAttribute(node, "multiple") === "false" && chosen.length > 1) { + this.add("too-many-answer-choices", "This question accepts one choice", path, answer); + } + } + + let previous = elements.filter(item => item.name === "Previous"); + if (previous.length > 1) { + this.add("duplicate-previous", "Question accepts at most one Previous", path, node); + } + for (let item of previous) { + let raw = stringAttribute(item, "choices"); + let value = stringAttribute(item, "value"); + if ((raw === undefined) === (value === undefined)) { + this.add( + "invalid-previous-answer", + "Previous requires choices or value, but not both", + path, + item, + ); + } + if (raw === undefined) continue; + let chosen = raw.trim().split(/\s+/).filter(Boolean); + if (chosen.length > limits.MAX_OPTIONS) { + this.add( + "too-many-previous-choices", + `Previous accepts at most ${limits.MAX_OPTIONS} choices`, + path, + item, + ); + } + if (chosen.some(id => !options.has(id))) { + this.add( + "unknown-previous-choice", + "Previous choices must name options in this question", + path, + item, + ); + } + if (new Set(chosen).size !== chosen.length) { + this.add( + "duplicate-previous-choice", + "Previous choices cannot repeat an option", + path, + item, + ); + } + if (stringAttribute(node, "multiple") === "false" && chosen.length > 1) { + this.add("too-many-previous-choices", "This question accepts one choice", path, item); + } + } + } + #attributes(node: Jsx, spec: dialect.Component, path: string): void { let seen = new Map(); @@ -274,7 +371,7 @@ class Validator { } } - #content(node: Jsx, spec: dialect.Component, path: string): void { + #content(node: Jsx, spec: dialect.Component, path: string, parent?: Jsx): void { let kids = children(node); switch (spec.content.type) { @@ -302,7 +399,15 @@ class Validator { } found++; } - if (found === 0) { + let pendingQuestion = spec.name === "Question" && parent?.name === "Questionnaire" + && !!stringAttribute(parent, "thread") + && children(parent).filter(child => isJsx(child) && child.name === "Question").length + === 1 + && ["open", "reopened", "discarded"].includes( + stringAttribute(parent, "status") ?? "", + ) + && stringAttribute(node, "multiple") === "false"; + if (found === 0 && !pendingQuestion) { this.add( "missing-children", `\`${spec.name}\` requires at least one ${allowed.join(" or ")}`, diff --git a/packages/protocol/chat.d.ts b/packages/protocol/chat.d.ts index 4ae29cd8..4991c2ec 100644 --- a/packages/protocol/chat.d.ts +++ b/packages/protocol/chat.d.ts @@ -1,4 +1,4 @@ -import type { Frame, Request } from "./index"; +import type { ConversationPlan, Frame, Request } from "./index"; type KIND = Frame & { kind: K }; @@ -72,6 +72,31 @@ export declare namespace Chat { author: Author; text: string; ts: number; + /** A system notice about a decision card; text remains its fallback. */ + decision?: + | { + questionnaireId: string; + kind: "prompt"; + generation: number; + label?: string; + /** Stable source identity for a refreshed advisory; absent on legacy prompts. */ + sourceMessageIds?: string[]; + suggestedOptionId?: string; + } + | { + questionnaireId: string; + kind: "scoped-choice"; + threadId: string; + proposalId: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + generation: number; + triggerEventId: string; + sources: ConversationPlan.SourceRef[]; + } + | { questionnaireId: string; kind: "activity"; label?: string; generation?: never }; /** True while the agent is still writing this one. */ streaming?: boolean; tools?: Activity[]; diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index 6c2a8b14..2d012c35 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -1,12 +1,13 @@ import type { Frame, Request } from "./index"; +import type { ConversationPlan } from "./conversation-plan"; type KIND = Frame & { kind: K }; /** * Collaborative questions. * - * A questionnaire is immutable once asked. Its answer is not: it lives in a - * shared CRDT owned by the server, so everyone present converges on one draft + * A questionnaire's question is fixed once asked; people may add options to it. + * Its answer lives in a shared CRDT owned by the server, so everyone present converges on one draft * before somebody submits it back to the agent that is waiting on it. * * There is one answer, not one per person, because there is one decision. Any @@ -17,17 +18,26 @@ export declare namespace Question { export type Incoming = | Request | Request + | Request | Request | Request + | Request + | Request | Presence.Input; export type Outgoing = | Sync | Asked + | Meta + | Metas | Open.Reply | Edit.Reply + | AddOption.Reply + | Changed | Submit.Reply | Cancel.Reply + | Discard.Reply + | Reopen.Reply | Presence.Output | Resolved; @@ -49,6 +59,49 @@ export declare namespace Question { questions: Item[]; }; + export type CardStatus = "open" | "decided" | "reopened" | "discarded"; + + export type OptionOrigin = { + origin: "chat" | "planner" | "human"; + rationale?: string; + by?: string; + /** Exact saved option quote or a question mention from this card's thread. */ + source?: ConversationPlan.SourceRef; + }; + + export type DecisionEntry = { + choices: string[]; + answers?: { [questionId: string]: string }; + owner: string; + at: number; + }; + + export type CardMeta = { + status: CardStatus; + origin: "planner" | "conversation"; + thread?: string; + owner?: string; + decidedAt?: number; + /** The member who most recently resolved this card. */ + resolver?: string; + /** Owner first, then other people in this decision. At most eight. */ + involved: string[]; + /** Advisory choice and the card revision at which it became visible. */ + suggested?: { optionId: string; messageIds: string[]; revision: number }; + history: DecisionEntry[]; + optionOrigins: { [optionId: string]: OptionOrigin }; + /** A refine job is pending or running for this card. */ + refining: boolean; + hasProse: boolean; + proseOrphaned: boolean; + }; + + export type Meta = KIND<"question:meta"> & { id: string; meta: CardMeta }; + + export type Metas = KIND<"question:metas"> & { + cards: Array<{ id: string; meta: CardMeta }>; + }; + /** One independently persisted decision card. */ export type DecisionDefinition = { questions: [Item]; @@ -154,6 +207,29 @@ export declare namespace Question { ); } + export namespace AddOption { + /** A person grows the decision. The server mints the option id. */ + export type Ask = KIND<"question:add-option"> & { id: string; label: string }; + + export type Reply = + & KIND<"question:add-option"> + & { id: string } + & ( + | { ok: true; option: Option; revision: number } + | { + ok: false; + reason: "full" | "duplicate" | "invalid" | "closed"; + message: string; + } + ); + } + + export type Changed = KIND<"question:changed"> & { + id: string; + definition: DecisionDefinition; + revision: number; + }; + export namespace Presence { export type Input = KIND<"question:presence"> & Focus & { id: string }; export type Output = Input & { client: string; handle: string }; @@ -164,6 +240,8 @@ export declare namespace Question { id: string; /** The draft revision being submitted, for optimistic concurrency. */ revision: number; + /** Accept this visible advisory choice only if it is still current. */ + suggestedOptionId?: string; }; export type Reply = @@ -203,6 +281,36 @@ export declare namespace Question { ); } + export namespace Discard { + /** A member sets a card aside, keeping its decision history in the document. */ + export type Ask = KIND<"question:discard"> & { id: string }; + + export type Reply = + & KIND<"question:discard"> + & { id: string } + & ( + | { ok: true; resolver: string } + | { ok: false; reason: "resolving" } + | { + ok: false; + reason: "resolved"; + status: "discarded"; + resolver: string; + } + ); + } + + export namespace Reopen { + export type Ask = KIND<"question:reopen"> & { id: string }; + export type Reply = + & KIND<"question:reopen"> + & { id: string } + & ( + | { ok: true } + | { ok: false; reason: "not-decided" | "resolving" } + ); + } + /** The questionnaire is closed. Nobody may answer it further. */ export type Resolved = KIND<"question:resolved"> & { id: string; From a9a74b4b440851bce9e9701406801692da7debb2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:32:32 +0100 Subject: [PATCH 038/152] Integrate durable conversation processing and scoped Planner effects --- apps/server/src/agent/card-tool-context.ts | 10 + .../server/src/agent/decision-prose-1.test.ts | 53 ++ .../server/src/agent/decision-prose-2.test.ts | 98 ++++ .../server/src/agent/decision-prose-3.test.ts | 48 ++ .../server/src/agent/decision-prose-4.test.ts | 70 +++ .../server/src/agent/decision-prose-5.test.ts | 56 ++ .../server/src/agent/decision-prose-fields.ts | 16 + .../src/agent/decision-prose.test-fixtures.ts | 112 ++++ apps/server/src/agent/decision-prose.ts | 104 ++++ apps/server/src/agent/heading-harness.test.ts | 8 +- apps/server/src/agent/refine-fields.ts | 96 ++++ apps/server/src/agent/refine-placement.ts | 43 ++ apps/server/src/agent/refine-tool-1.test.ts | 129 +++++ apps/server/src/agent/refine-tool-2.test.ts | 36 ++ apps/server/src/agent/refine-tool-3.test.ts | 161 ++++++ apps/server/src/agent/refine-tool-4.test.ts | 109 ++++ apps/server/src/agent/refine-tool-5.test.ts | 107 ++++ apps/server/src/agent/refine-tool-6.test.ts | 96 ++++ apps/server/src/agent/refine-tool-7.test.ts | 80 +++ apps/server/src/agent/refine-tool-8.test.ts | 116 ++++ apps/server/src/agent/refine-tool-9.test.ts | 38 ++ .../src/agent/refine-tool.test-fixtures.ts | 188 +++++++ apps/server/src/agent/refine-tool.ts | 181 ++++++ apps/server/src/agent/revise-fields.ts | 57 ++ .../src/agent/revise-open-decision-1.test.ts | 167 ++++++ .../src/agent/revise-open-decision-2.test.ts | 129 +++++ .../src/agent/revise-open-decision-3.test.ts | 87 +++ .../src/agent/revise-open-decision-4.test.ts | 86 +++ .../src/agent/revise-open-decision-5.test.ts | 113 ++++ .../revise-open-decision-requests.test.ts | 124 +++++ .../revise-open-decision.test-fixtures.ts | 58 ++ apps/server/src/agent/revise-open-decision.ts | 69 +++ apps/server/src/agent/revise-requests.ts | 90 +++ apps/server/src/agent/scoped-prose-tool.ts | 37 ++ apps/server/src/agent/scoped-refine-tool.ts | 80 +++ .../src/agent/scoped-request-boundary.test.ts | 47 ++ apps/server/src/agent/scoped-revise-tool.ts | 53 ++ apps/server/src/agent/scoped-tool-result.ts | 19 + .../src/agent/scoped-tools.test-bridge.ts | 25 + .../agent/scoped-tools.test-configuration.ts | 86 +++ apps/server/src/agent/scoped-tools.ts | 7 + apps/server/src/agent/tools.ts | 6 + .../job-authenticated-memory.test-fixtures.ts | 97 ++++ apps/server/src/chat/job-boundary.test.ts | 71 +++ .../src/chat/job-card-memory.test-fixtures.ts | 131 +++++ apps/server/src/chat/job-card-memory.test.ts | 54 ++ apps/server/src/chat/job-fence.ts | 20 + apps/server/src/chat/job-handoff.test.ts | 47 ++ .../src/chat/job-harness.test-fixtures.ts | 68 +++ .../src/chat/job-heading-fencing.test.ts | 134 +++++ .../chat/job-heading-harness.test-fixtures.ts | 100 ++++ .../chat/job-heading-memory.test-fixtures.ts | 69 +++ .../src/chat/job-heading-memory.test.ts | 75 +++ apps/server/src/chat/job-lifecycle.test.ts | 77 +++ apps/server/src/chat/job-notice.test.ts | 26 + apps/server/src/chat/job-profiles.test.ts | 110 ++++ apps/server/src/chat/job-queue.test.ts | 83 +++ apps/server/src/chat/job-queue.ts | 152 +++++ apps/server/src/chat/job-signal.test.ts | 56 ++ .../src/chat/job-stream.test-fixtures.ts | 109 ++++ apps/server/src/chat/job-tools.test.ts | 96 ++++ apps/server/src/chat/job-translate.ts | 67 +++ apps/server/src/chat/job.test-fixtures.ts | 58 ++ apps/server/src/chat/job.test.ts | 95 ++++ apps/server/src/chat/service.ts | 230 ++++++-- .../src/config-conversation-bounds.test.ts | 44 ++ .../src/config-conversation.test-fixtures.ts | 46 ++ apps/server/src/config-conversation.test.ts | 32 ++ apps/server/src/config.ts | 27 + .../conversation-plan/card-effect-prompts.ts | 107 ++++ .../conversation-plan/card-effect-scoped.ts | 116 ++++ .../card-effect-suggestion.ts | 50 ++ .../conversation-plan/card-effect-targets.ts | 87 +++ .../conversation-plan/card-mirror-runner.ts | 43 ++ .../src/conversation-plan/card-prompts.ts | 42 ++ .../src/conversation-plan/cards-1.test.ts | 143 +++++ .../src/conversation-plan/cards-2.test.ts | 134 +++++ .../src/conversation-plan/cards-3.test.ts | 207 +++++++ .../src/conversation-plan/cards-4.test.ts | 157 ++++++ .../src/conversation-plan/cards-5.test.ts | 145 +++++ .../src/conversation-plan/cards-6.test.ts | 181 ++++++ .../src/conversation-plan/cards-7.test.ts | 223 ++++++++ .../src/conversation-plan/cards-8.test.ts | 206 +++++++ .../src/conversation-plan/cards-9.test.ts | 105 ++++ .../conversation-plan/cards.test-fixtures.ts | 194 +++++++ apps/server/src/conversation-plan/cards.ts | 35 ++ apps/server/src/conversation-plan/commands.ts | 130 +++++ .../runtime-acceptance.test.ts | 85 +++ .../conversation-plan/runtime-card.test.ts | 132 +++++ .../runtime-lifecycle.test.ts | 179 ++++++ apps/server/src/conversation-plan/runtime.ts | 187 +++++++ .../conversation-plan/save-command.test.ts | 191 +++++++ .../src/conversation-plan/save-command.ts | 59 ++ .../conversation-plan/service-order.test.ts | 1 + apps/server/src/conversation-plan/service.ts | 10 + apps/server/src/harness/agents.ts | 16 +- apps/server/src/harness/session.ts | 21 +- apps/server/src/harness/tool-names.ts | 18 + apps/server/src/main.ts | 111 +++- apps/server/src/mcp/update-lifecycle.test.ts | 2 +- apps/server/src/plan/questionnaires.ts | 8 +- apps/server/src/plan/room.ts | 83 ++- apps/server/src/plan/service.ts | 16 +- ...question-service-deferral.test-fixtures.ts | 111 ++++ .../src/question-service.test-fixtures.ts | 120 ++++ .../questions-service-durability-1.test.ts | 186 +++++++ .../questions-service-durability-2.test.ts | 178 ++++++ .../questions-service-durability-3.test.ts | 213 +++++++ .../questions-service-durability-4.test.ts | 217 ++++++++ .../questions-service-durability-5.test.ts | 214 +++++++ .../questions-service-durability-6.test.ts | 165 ++++++ .../questions-service-durability-7.test.ts | 160 ++++++ .../questions-service-durability-8.test.ts | 217 ++++++++ .../questions-service-durability-9.test.ts | 88 +++ apps/server/src/questions.service.test.ts | 62 +-- apps/server/src/questions/card-insertion.ts | 133 +++++ .../src/questions/card-notifications.ts | 82 +++ .../src/questions/card-option-relabel.ts | 163 ++++++ .../src/questions/card-planner-revision.ts | 145 +++++ .../src/questions/card-reopen-1.test.ts | 211 +++++++ .../src/questions/card-reopen-2.test.ts | 147 +++++ .../questions/card-reopen.test-fixtures.ts | 110 ++++ apps/server/src/questions/card-retitle.ts | 93 ++++ .../src/questions/card-server-options.ts | 230 ++++++++ apps/server/src/questions/card-suggestions.ts | 57 ++ .../src/questions/conversation-card-1.test.ts | 201 +++++++ .../src/questions/conversation-card-2.test.ts | 197 +++++++ .../src/questions/conversation-card-3.test.ts | 214 +++++++ .../src/questions/conversation-card-4.test.ts | 150 +++++ .../src/questions/conversation-card-5.test.ts | 120 ++++ .../src/questions/conversation-card-6.test.ts | 171 ++++++ .../src/questions/conversation-card-7.test.ts | 205 +++++++ .../src/questions/conversation-card-8.test.ts | 207 +++++++ .../conversation-card.test-fixtures.ts | 130 +++++ apps/server/src/questions/option-match.ts | 45 ++ apps/server/src/questions/prose-1.test.ts | 211 +++++++ apps/server/src/questions/prose-2.test.ts | 179 ++++++ apps/server/src/questions/prose-3.test.ts | 135 +++++ .../src/questions/prose.test-fixtures.ts | 50 ++ apps/server/src/questions/prose.ts | 60 ++ .../question-option-source-1.test.ts | 202 +++++++ .../question-option-source-2.test.ts | 56 ++ .../question-option-source.test-fixtures.ts | 106 ++++ .../src/questions/service-add-option.ts | 169 ++++++ apps/server/src/questions/service-ask.ts | 160 ++++++ apps/server/src/questions/service-cancel.ts | 73 +++ .../src/questions/service-definition.ts | 71 +++ apps/server/src/questions/service-discard.ts | 139 +++++ apps/server/src/questions/service-draft.ts | 117 ++++ .../src/questions/service-relationships.ts | 128 +++++ apps/server/src/questions/service-reopen.ts | 212 +++++++ apps/server/src/questions/service-submit.ts | 220 ++++++++ apps/server/src/questions/service.ts | 526 +----------------- apps/server/src/questions/write-prose.test.ts | 152 +++++ apps/server/src/questions/write-prose.ts | 91 +++ docs/jev-product-extraction.md | 64 +++ packages/protocol/index.d.ts | 2 + packages/protocol/plan.d.ts | 11 + 158 files changed, 16335 insertions(+), 634 deletions(-) create mode 100644 apps/server/src/agent/card-tool-context.ts create mode 100644 apps/server/src/agent/decision-prose-1.test.ts create mode 100644 apps/server/src/agent/decision-prose-2.test.ts create mode 100644 apps/server/src/agent/decision-prose-3.test.ts create mode 100644 apps/server/src/agent/decision-prose-4.test.ts create mode 100644 apps/server/src/agent/decision-prose-5.test.ts create mode 100644 apps/server/src/agent/decision-prose-fields.ts create mode 100644 apps/server/src/agent/decision-prose.test-fixtures.ts create mode 100644 apps/server/src/agent/decision-prose.ts create mode 100644 apps/server/src/agent/refine-fields.ts create mode 100644 apps/server/src/agent/refine-placement.ts create mode 100644 apps/server/src/agent/refine-tool-1.test.ts create mode 100644 apps/server/src/agent/refine-tool-2.test.ts create mode 100644 apps/server/src/agent/refine-tool-3.test.ts create mode 100644 apps/server/src/agent/refine-tool-4.test.ts create mode 100644 apps/server/src/agent/refine-tool-5.test.ts create mode 100644 apps/server/src/agent/refine-tool-6.test.ts create mode 100644 apps/server/src/agent/refine-tool-7.test.ts create mode 100644 apps/server/src/agent/refine-tool-8.test.ts create mode 100644 apps/server/src/agent/refine-tool-9.test.ts create mode 100644 apps/server/src/agent/refine-tool.test-fixtures.ts create mode 100644 apps/server/src/agent/refine-tool.ts create mode 100644 apps/server/src/agent/revise-fields.ts create mode 100644 apps/server/src/agent/revise-open-decision-1.test.ts create mode 100644 apps/server/src/agent/revise-open-decision-2.test.ts create mode 100644 apps/server/src/agent/revise-open-decision-3.test.ts create mode 100644 apps/server/src/agent/revise-open-decision-4.test.ts create mode 100644 apps/server/src/agent/revise-open-decision-5.test.ts create mode 100644 apps/server/src/agent/revise-open-decision-requests.test.ts create mode 100644 apps/server/src/agent/revise-open-decision.test-fixtures.ts create mode 100644 apps/server/src/agent/revise-open-decision.ts create mode 100644 apps/server/src/agent/revise-requests.ts create mode 100644 apps/server/src/agent/scoped-prose-tool.ts create mode 100644 apps/server/src/agent/scoped-refine-tool.ts create mode 100644 apps/server/src/agent/scoped-request-boundary.test.ts create mode 100644 apps/server/src/agent/scoped-revise-tool.ts create mode 100644 apps/server/src/agent/scoped-tool-result.ts create mode 100644 apps/server/src/agent/scoped-tools.test-bridge.ts create mode 100644 apps/server/src/agent/scoped-tools.test-configuration.ts create mode 100644 apps/server/src/agent/scoped-tools.ts create mode 100644 apps/server/src/chat/job-authenticated-memory.test-fixtures.ts create mode 100644 apps/server/src/chat/job-boundary.test.ts create mode 100644 apps/server/src/chat/job-card-memory.test-fixtures.ts create mode 100644 apps/server/src/chat/job-card-memory.test.ts create mode 100644 apps/server/src/chat/job-fence.ts create mode 100644 apps/server/src/chat/job-handoff.test.ts create mode 100644 apps/server/src/chat/job-harness.test-fixtures.ts create mode 100644 apps/server/src/chat/job-heading-fencing.test.ts create mode 100644 apps/server/src/chat/job-heading-harness.test-fixtures.ts create mode 100644 apps/server/src/chat/job-heading-memory.test-fixtures.ts create mode 100644 apps/server/src/chat/job-heading-memory.test.ts create mode 100644 apps/server/src/chat/job-lifecycle.test.ts create mode 100644 apps/server/src/chat/job-notice.test.ts create mode 100644 apps/server/src/chat/job-profiles.test.ts create mode 100644 apps/server/src/chat/job-queue.test.ts create mode 100644 apps/server/src/chat/job-queue.ts create mode 100644 apps/server/src/chat/job-signal.test.ts create mode 100644 apps/server/src/chat/job-stream.test-fixtures.ts create mode 100644 apps/server/src/chat/job-tools.test.ts create mode 100644 apps/server/src/chat/job-translate.ts create mode 100644 apps/server/src/chat/job.test-fixtures.ts create mode 100644 apps/server/src/chat/job.test.ts create mode 100644 apps/server/src/config-conversation-bounds.test.ts create mode 100644 apps/server/src/config-conversation.test-fixtures.ts create mode 100644 apps/server/src/config-conversation.test.ts create mode 100644 apps/server/src/conversation-plan/card-effect-prompts.ts create mode 100644 apps/server/src/conversation-plan/card-effect-scoped.ts create mode 100644 apps/server/src/conversation-plan/card-effect-suggestion.ts create mode 100644 apps/server/src/conversation-plan/card-effect-targets.ts create mode 100644 apps/server/src/conversation-plan/card-mirror-runner.ts create mode 100644 apps/server/src/conversation-plan/card-prompts.ts create mode 100644 apps/server/src/conversation-plan/cards-1.test.ts create mode 100644 apps/server/src/conversation-plan/cards-2.test.ts create mode 100644 apps/server/src/conversation-plan/cards-3.test.ts create mode 100644 apps/server/src/conversation-plan/cards-4.test.ts create mode 100644 apps/server/src/conversation-plan/cards-5.test.ts create mode 100644 apps/server/src/conversation-plan/cards-6.test.ts create mode 100644 apps/server/src/conversation-plan/cards-7.test.ts create mode 100644 apps/server/src/conversation-plan/cards-8.test.ts create mode 100644 apps/server/src/conversation-plan/cards-9.test.ts create mode 100644 apps/server/src/conversation-plan/cards.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/cards.ts create mode 100644 apps/server/src/conversation-plan/commands.ts create mode 100644 apps/server/src/conversation-plan/runtime-acceptance.test.ts create mode 100644 apps/server/src/conversation-plan/runtime-card.test.ts create mode 100644 apps/server/src/conversation-plan/runtime-lifecycle.test.ts create mode 100644 apps/server/src/conversation-plan/runtime.ts create mode 100644 apps/server/src/conversation-plan/save-command.test.ts create mode 100644 apps/server/src/conversation-plan/save-command.ts create mode 100644 apps/server/src/question-service-deferral.test-fixtures.ts create mode 100644 apps/server/src/question-service.test-fixtures.ts create mode 100644 apps/server/src/questions-service-durability-1.test.ts create mode 100644 apps/server/src/questions-service-durability-2.test.ts create mode 100644 apps/server/src/questions-service-durability-3.test.ts create mode 100644 apps/server/src/questions-service-durability-4.test.ts create mode 100644 apps/server/src/questions-service-durability-5.test.ts create mode 100644 apps/server/src/questions-service-durability-6.test.ts create mode 100644 apps/server/src/questions-service-durability-7.test.ts create mode 100644 apps/server/src/questions-service-durability-8.test.ts create mode 100644 apps/server/src/questions-service-durability-9.test.ts create mode 100644 apps/server/src/questions/card-insertion.ts create mode 100644 apps/server/src/questions/card-notifications.ts create mode 100644 apps/server/src/questions/card-option-relabel.ts create mode 100644 apps/server/src/questions/card-planner-revision.ts create mode 100644 apps/server/src/questions/card-reopen-1.test.ts create mode 100644 apps/server/src/questions/card-reopen-2.test.ts create mode 100644 apps/server/src/questions/card-reopen.test-fixtures.ts create mode 100644 apps/server/src/questions/card-retitle.ts create mode 100644 apps/server/src/questions/card-server-options.ts create mode 100644 apps/server/src/questions/card-suggestions.ts create mode 100644 apps/server/src/questions/conversation-card-1.test.ts create mode 100644 apps/server/src/questions/conversation-card-2.test.ts create mode 100644 apps/server/src/questions/conversation-card-3.test.ts create mode 100644 apps/server/src/questions/conversation-card-4.test.ts create mode 100644 apps/server/src/questions/conversation-card-5.test.ts create mode 100644 apps/server/src/questions/conversation-card-6.test.ts create mode 100644 apps/server/src/questions/conversation-card-7.test.ts create mode 100644 apps/server/src/questions/conversation-card-8.test.ts create mode 100644 apps/server/src/questions/conversation-card.test-fixtures.ts create mode 100644 apps/server/src/questions/option-match.ts create mode 100644 apps/server/src/questions/prose-1.test.ts create mode 100644 apps/server/src/questions/prose-2.test.ts create mode 100644 apps/server/src/questions/prose-3.test.ts create mode 100644 apps/server/src/questions/prose.test-fixtures.ts create mode 100644 apps/server/src/questions/prose.ts create mode 100644 apps/server/src/questions/question-option-source-1.test.ts create mode 100644 apps/server/src/questions/question-option-source-2.test.ts create mode 100644 apps/server/src/questions/question-option-source.test-fixtures.ts create mode 100644 apps/server/src/questions/service-add-option.ts create mode 100644 apps/server/src/questions/service-ask.ts create mode 100644 apps/server/src/questions/service-cancel.ts create mode 100644 apps/server/src/questions/service-definition.ts create mode 100644 apps/server/src/questions/service-discard.ts create mode 100644 apps/server/src/questions/service-draft.ts create mode 100644 apps/server/src/questions/service-relationships.ts create mode 100644 apps/server/src/questions/service-reopen.ts create mode 100644 apps/server/src/questions/service-submit.ts create mode 100644 apps/server/src/questions/write-prose.test.ts create mode 100644 apps/server/src/questions/write-prose.ts 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.ts b/apps/server/src/agent/heading-harness.test.ts index e9887c42..fcd86809 100644 --- a/apps/server/src/agent/heading-harness.test.ts +++ b/apps/server/src/agent/heading-harness.test.ts @@ -101,13 +101,13 @@ test("actual execution boundary refuses foreign writes even when a harness keeps 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: "prose", target: "thread" }; + 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: "Background prose", + instructions: "Unsupported background kind", }, { githubTools: async () => { called = true; @@ -118,7 +118,9 @@ test("unsupported background jobs cannot open an ordinary writable Planner sessi 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 prose tools are not available"); + 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 { 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 bb8e57bc..bc615315 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-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-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/service.ts b/apps/server/src/chat/service.ts index 2e691fd7..e2019298 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -30,6 +30,11 @@ 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"; @@ -73,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. */ @@ -84,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 = { @@ -109,6 +116,10 @@ export type Chat = { 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. */ @@ -411,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; @@ -506,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, @@ -682,11 +699,21 @@ export function instruct( 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); } @@ -730,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"); @@ -918,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(); @@ -996,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 @@ -1019,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(); @@ -1028,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" }, @@ -1061,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; @@ -1074,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. @@ -1094,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, @@ -1111,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( @@ -1129,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; @@ -1166,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": @@ -1335,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; @@ -1344,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.ts b/apps/server/src/config.ts index e5b13483..ca0a3db7 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. * @@ -94,6 +98,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 { @@ -103,6 +124,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), @@ -132,6 +156,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/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..97800c3c --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-targets.ts @@ -0,0 +1,87 @@ +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"); + if ( + result.reason === "closed" && Questions.isOpenStatus( + plan.records.get(id)?.status ?? "cancelled", + ) + ) 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-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..38b7cf90 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-8.test.ts @@ -0,0 +1,206 @@ +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(); +}); + +test("an unrelated optionless objection by a supporter keeps scoped evidence", 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 previous = structuredClone(before.decision); + let quote = "I oppose making the toolbar blue."; + await fixture.deliver({ + id: "rob-toolbar-objection", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "scoped-rob-toolbar", + author: { kind: "member", handle: "rob" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + expect(fixture.context.plan.chat.entries.find(entry => entry.id === before.id)?.decision) + .toEqual(previous); + expect(fixture.state().threads[0]?.pendingScopedChoice?.proposalId) + .toBe(fixture.proposal.id); + expect(fixture.errors).toEqual([]); +}); + +test("an unrelated optionless objection by the proposer keeps scoped Save active", async () => { + let fixture = await scopedNoticeFixture(false); + 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); + let quote = "I oppose making the toolbar blue."; + await fixture.deliver({ + id: "mei-toolbar-objection", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-toolbar", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + 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([]); +}); + +test("an unlinked named-option objection keeps the scoped Save notice active", async () => { + let fixture = await scopedNoticeFixture(false); + 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); + let quote = "I oppose GitHub Apps."; + await fixture.deliver({ + id: "mei-unlinked-named-objection", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-unlinked-named", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + optionId: OPTION, + 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.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/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/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.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..4dc47de9 --- /dev/null +++ b/apps/server/src/conversation-plan/save-command.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +type Member = Extract; +type SaveCommandFrame = ConversationPlan.ScopedChoiceSave & { + kind: "conversation-plan:scoped-choice-save"; + 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; +}; +type HandleScopedChoiceSave = (frame: SaveCommandFrame, deps: RouteDeps) => Promise; + +let routeUrl = new URL("./save-command.ts", import.meta.url).href; +let route = await import(routeUrl).catch(() => undefined) as + | { handleScopedChoiceSave?: HandleScopedChoiceSave } + | undefined; + +function handler(): HandleScopedChoiceSave { + expect(route?.handleScopedChoiceSave).toBeFunction(); + return route!.handleScopedChoiceSave!; +} + +function frame(overrides: Partial = {}): SaveCommandFrame { + return { + kind: "conversation-plan:scoped-choice-save", + 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 = handler()(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 handler()(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 handler()(request, setup.deps); + expect(setup.calls).toHaveLength(1); + expect(setup.replies).toEqual([]); + expect(setup.failures).toEqual([{ + rid: request.rid, + message: "scoped choice card or proposal is stale", + }]); + }); +}); diff --git a/apps/server/src/conversation-plan/save-command.ts b/apps/server/src/conversation-plan/save-command.ts new file mode 100644 index 00000000..f60492ea --- /dev/null +++ b/apps/server/src/conversation-plan/save-command.ts @@ -0,0 +1,59 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { AuthorizationResult } from "../wire"; +import type { Processor } from "./service"; + +type Member = Extract; +type Current = T | (() => T); + +export type ScopedChoiceSaveRoute = { + refreshAccess: () => Promise; + conversationPlanEnabled: Current; + canEdit: Current; + archived: Current; + roomClosing: Current; + actor: Current; + processor?: Current | undefined>; + reply: (rid: string, frame: ConversationPlan.SavedScopedChoice) => void; + fail: (rid: string, message: string) => void; +}; + +function current(value: Current): T { + return typeof value === "function" ? (value as () => T)() : value; +} + +/** Receive only the authenticated socket's member identity and bounded command fields. */ +export async function handleScopedChoiceSave( + frame: ConversationPlan.SaveScopedChoice & { rid: string }, + deps: ScopedChoiceSaveRoute, +): Promise { + try { + let access = await deps.refreshAccess(); + if (access === "unavailable") throw new Error("authorization is temporarily unavailable"); + if (access !== "allowed") throw new Error("authorization expired"); + if (!current(deps.conversationPlanEnabled)) { + throw new Error("conversation analysis is disabled"); + } + if (!current(deps.canEdit) || current(deps.archived) || current(deps.roomClosing)) { + throw new Error("repository write access is required"); + } + let processor = deps.processor && current(deps.processor); + if (!processor) throw new Error("document is not open"); + let actor = current(deps.actor); + let result = await processor.saveScopedChoice({ + actionId: frame.actionId, + threadId: frame.threadId, + expectedVersion: frame.expectedVersion, + proposalId: frame.proposalId, + cardId: frame.cardId, + optionId: frame.optionId, + ...(frame.expectedLabel === undefined ? {} : { expectedLabel: frame.expectedLabel }), + expectedGeneration: frame.expectedGeneration, + }, { kind: "member", handle: actor.handle }); + deps.reply(frame.rid, { kind: "conversation-plan:scoped-choice-save", ts: 0, ...result }); + } catch (error) { + deps.fail( + frame.rid, + error instanceof Error ? error.message : "cannot save scoped choice", + ); + } +} diff --git a/apps/server/src/conversation-plan/service-order.test.ts b/apps/server/src/conversation-plan/service-order.test.ts index 133b0e20..1be4d687 100644 --- a/apps/server/src/conversation-plan/service-order.test.ts +++ b/apps/server/src/conversation-plan/service-order.test.ts @@ -43,6 +43,7 @@ test("processor captures plan and initial effects once before reporting dependen "setEffects", "wake", "stop", + "idle", ]); await processor.accept(entry("captured-plan", "Which auth system?")); expect(planReads).toBe(1); diff --git a/apps/server/src/conversation-plan/service.ts b/apps/server/src/conversation-plan/service.ts index a1a19b6a..535815b1 100644 --- a/apps/server/src/conversation-plan/service.ts +++ b/apps/server/src/conversation-plan/service.ts @@ -79,6 +79,15 @@ export function createProcessor(deps: Dependencies) { controller.abort(); } + /** Wait for a stopped processor's already-started effects before closing its document. */ + async function idle(): Promise { + let task = running; + while (task) { + await task; + task = running; + } + } + return { accept, afterMessage, @@ -90,5 +99,6 @@ export function createProcessor(deps: Dependencies) { setEffects, wake, stop, + idle, }; } diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index f50db1a9..f190b355 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -5,10 +5,11 @@ 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 { WRITE_TOOLS } from "../agent/job-scope"; +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"; @@ -34,6 +35,7 @@ const plannerTools: ToolSet = { ...repositories, ...githubPlaceholders, ...jobTools, + ...createScopedTools(), }; export { PLANNER_TOOL_NAMES } from "./tool-names"; @@ -46,14 +48,12 @@ type PlannerCallOptions = { model?: string; }; -export function createPlannerAgent(harness: HarnessV1, profile?: "heading") { - let names = profile === "heading" - ? [...PLANNER_TOOL_NAMES.filter(name => !WRITE_TOOLS.has(name)), "draft_heading"] - : PLANNER_TOOL_NAMES; +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: names, + activeTools: [...names], permissionMode: "allow-reads", callOptionsSchema: z.custom(), prepareCall: ({ options, ...rest }) => { @@ -74,6 +74,8 @@ export function createPlannerAgent(harness: HarnessV1, profile?: "heading") { 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 b9629065..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 { headingPlannerAgent, plannerAgent } from "./agents"; +import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; @@ -26,6 +27,8 @@ type PlannerChannel = { }; export type PlannerSession = { + /** Snapshot of the selected current static Planner profile. */ + activeTools?: readonly string[]; stream: (prompt: string, abortSignal: AbortSignal) => ReturnType; destroy: () => Promise; }; @@ -33,6 +36,8 @@ export type PlannerSession = { export type PlannerSessionDependencies = { agent?: PlannerAgent; headingAgent?: PlannerAgent; + refineAgent?: PlannerAgent; + proseAgent?: PlannerAgent; githubTools?: typeof githubTools; createSandbox?: () => Promise; registerCredential?: typeof registerCredential; @@ -45,7 +50,7 @@ export async function openPlannerSession( deps: PlannerSessionDependencies = {}, ): Promise> { let job = channel.room.plan.chat?.job; - if (job && job.kind !== "heading") { + if (job && !Object.hasOwn(BACKGROUND_TOOL_NAMES, job.kind)) { return { ok: false, error: { @@ -84,8 +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 - ?? (job ? deps.headingAgent ?? headingPlannerAgent : 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( @@ -106,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 48d2d69a..90f5f43e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -16,6 +16,8 @@ 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 { handleConversationCommand } from "./conversation-plan/commands"; import { registerChannelRoutes } from "./channels/routes"; import * as Comments from "./comments/service"; import { proxy, serve } from "./client"; @@ -88,6 +90,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(); @@ -128,7 +135,15 @@ function presence(server: Server, room: Rooms.Room): void { async function plan(room: Rooms.Room, server: Server): Promise { if (room.closing) await room.closing; if (deletingChannels.has(room.id)) throw new Error("document is unavailable"); - if (room.plan) return room.plan; + if (room.opening) { + let opened = await room.opening; + conversationRuntime.wake(opened); + return opened; + } + if (room.plan) { + conversationRuntime.wake(room.plan); + return room.plan; + } let backend: Service.Backend = { storage, lease: () => { @@ -145,8 +160,9 @@ async function plan(room: Rooms.Room, server: Server): Promise {}); if (Inject.enabled()) Inject.ask(opened, server, room.id); @@ -155,7 +171,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, @@ -201,7 +220,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { }; } : undefined, - }; + }); } async function closeRoom(room: Rooms.Room, force = false): Promise { @@ -210,7 +229,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; @@ -272,6 +295,14 @@ async function receive(ws: Socket, raw: string): Promise { Questions.greet(opened, ws); Comments.greet(opened, ws); Chat.greet(opened.chat, ws); + if (config.conversationPlan) { + tell(ws, { + kind: "conversation-plan:snapshot", + ts: 0, + state: opened.conversationPlan, + jobs: opened.conversationPlanJobs, + }); + } } catch (err) { fail(ws, frame.rid, err instanceof Error ? err.message : "cannot open plan"); } @@ -302,6 +333,19 @@ 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 "question:open": if (room.plan) Questions.open(room.plan, ws, frame); return; @@ -310,12 +354,35 @@ async function receive(ws: Socket, raw: string): Promise { if (room.plan) await Questions.edit(room.plan, ws, frame); return; + case "question:add-option": + if (room.plan) await Questions.addOption(room.plan, server, room.id, 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": @@ -637,7 +704,13 @@ function drain(): Promise { if (result.status === "rejected") record(result.reason); } let rooms = await Promise.allSettled( - Rooms.all().map(room => room.plan && Service.close(room.plan)), + Rooms.all().map(async room => { + let opened = room.plan; + if (!opened) return; + let stopped = conversationRuntime.stop(opened); + await stopped; + await Service.close(opened); + }), ); for (let result of rooms) { if (result.status === "rejected") record(result.reason); @@ -767,7 +840,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); } @@ -781,6 +857,11 @@ async function archiveChannelLocked(channelId: string, now: Date) { throw err; } finally { archivingChannels.delete(channelId); + let current = Rooms.get(channelId); + if (current?.plan) { + let channel = await storage.channels.get(channelId); + await conversationRuntime.attach(current, current.plan, !!channel?.archivedAt); + } } } @@ -797,6 +878,8 @@ async function restoreChannelLocked(channelId: string, now: Date) { () => storage.channels.restore({ id: channelId, now }), ); 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; @@ -824,7 +907,11 @@ async function deleteChannelLocked(channelId: string): Promise { let activeRoom = Rooms.get(channelId); let active = activeRoom?.plan; if (activeRoom) activeRoom.plan = undefined; - if (active) await Service.close(active); + if (active) { + let stopped = conversationRuntime.stop(active); + await stopped; + await Service.close(active); + } return storage.channels.delete(channelId); }); if (!deleted) throw new StorageError("missing", `channel ${channelId} does not exist`); diff --git a/apps/server/src/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/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/room.ts b/apps/server/src/plan/room.ts index 5134bd2d..23b125d0 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,8 +20,9 @@ import * as Y from "yjs"; import { $createDecisionNode, $createPlanNodes, - $exportPlan, + $exportPlanTree, $importPlan, + assert, exportPlan, limits, parse, @@ -195,7 +196,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; } @@ -281,7 +288,11 @@ export async function restore( try { Y.applyUpdate(restored.doc, checkpoint, REMOTE); await settle(); - if (project(restored) !== source) { + // Older checkpoints serialized caret paragraphs; accept only their exact validated export. + if ( + project(restored) !== source + && exportPlan(restored.editor, { registry: schema() }) !== source + ) { throw new Error("stored plan source does not match its Yjs checkpoint"); } @@ -511,8 +522,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"); @@ -531,15 +542,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; }); } @@ -615,6 +658,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`. * diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 0ba260c7..47adc985 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1180,7 +1180,7 @@ 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)]); } /** Everything a joining client needs to start from. */ @@ -1197,6 +1197,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) } : {}), }); @@ -1495,7 +1496,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"); @@ -1609,13 +1610,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); + } + } } /** 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..4597e370 --- /dev/null +++ b/apps/server/src/questions-service-durability-1.test.ts @@ -0,0 +1,186 @@ +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 { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking, member } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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 model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([question.id, "choice"]).set(option); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let ana = member("ana"); + await Questions.edit(plan, ana.ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); + 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..c150f4eb --- /dev/null +++ b/apps/server/src/questions-service-durability-2.test.ts @@ -0,0 +1,178 @@ +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, member } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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 model = Question.crdt.Model.fromBinary(new Uint8Array(open.model)) + .fork() as unknown as Question.Model; + model.api.val([question.id, "choice"]).set(option); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let ana = member(); + await Questions.edit(plan, ana.ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); + 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 model = Question.crdt.Model.fromBinary(new Uint8Array(open.model)) + .fork() as unknown as Question.Model; + model.api.val([question.id, "choice"]).set(option); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let ana = member("ana"); + let ben = member("ben"); + await Questions.edit(plan, ana.ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); + 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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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 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([question.id, "choice"]).set(question.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)); + }, + publish() {}, + } as unknown as Socket; + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id: record.id, + patch: [...patch.toBinary()], + }); + 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..fcc6addd --- /dev/null +++ b/apps/server/src/questions-service-durability-6.test.ts @@ -0,0 +1,165 @@ +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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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:add-option", + ts: 0, + rid: "grow", + id: record.id, + label: "GitHub Apps", + }); + expect(frames.find(frame => frame.kind === "question:add-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..c8567417 --- /dev/null +++ b/apps/server/src/questions-service-durability-7.test.ts @@ -0,0 +1,160 @@ +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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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:add-option", + 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:changed")).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:add-option"); + let changed = context.broadcasts.find(frame => frame.kind === "question:changed"); + 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, revision: 1, option: { label: "GitHub Apps" } }); + expect(frames.find(frame => frame.rid === "select")).toMatchObject({ + open: true, + accepted: true, + applied: true, + revision: 2, + }); + expect(changed).toMatchObject({ id, revision: 1 }); + 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:add-option", + ts: 0, + rid: "first", + id, + label: "GitHub Apps", + }), + Questions.addOption(plan, context.server, "test", ws, { + kind: "question:add-option", + 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(1); + expect(context.broadcasts.filter(frame => frame.kind === "question:changed")).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..c0019639 --- /dev/null +++ b/apps/server/src/questions-service-durability-8.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 * 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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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:add-option", + 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:add-option", + 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:add-option", + ts: 0, + rid: "missing", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ ok: false, reason: "closed", 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..f534e1cc --- /dev/null +++ b/apps/server/src/questions-service-durability-9.test.ts @@ -0,0 +1,88 @@ +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 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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +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:add-option", + ts: 0, + rid: "closed", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ ok: false, reason: "closed", 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 option labels are invalid instead of coerced into text", 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; + for (let [index, label] of [123, ["Nested"], { text: "Object" }].entries()) { + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:add-option", + ts: 0, + rid: `invalid-${index}`, + id, + label: label as unknown as string, + }); + } + + expect(frames).toHaveLength(3); + 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:changed")).toHaveLength(0); +}); diff --git a/apps/server/src/questions.service.test.ts b/apps/server/src/questions.service.test.ts index bb74aaf2..95914521 100644 --- a/apps/server/src/questions.service.test.ts +++ b/apps/server/src/questions.service.test.ts @@ -1,70 +1,22 @@ -import { afterEach, expect, test } from "bun:test"; +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 { 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(); 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-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..ee67a4d3 --- /dev/null +++ b/apps/server/src/questions/card-server-options.ts @@ -0,0 +1,230 @@ +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 { 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(); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} 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-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..ff6674b3 --- /dev/null +++ b/apps/server/src/questions/conversation-card-4.test.ts @@ -0,0 +1,150 @@ +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; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +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:add-option", + ts: 0, + rid: "add", + id, + label: "GitHub Apps", + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:add-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/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/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/service-add-option.ts b/apps/server/src/questions/service-add-option.ts new file mode 100644 index 00000000..e5784983 --- /dev/null +++ b/apps/server/src/questions/service-add-option.ts @@ -0,0 +1,169 @@ +import { ulid } from "@chopin/dialect"; + +import * as Y from "yjs"; + +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 async function addOption( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + if (typeof msg.label !== "string") { + return reply(ws, msg.rid, { + kind: "question:add-option", + ts: 0, + id: msg.id, + ok: false, + reason: "invalid", + message: "An option needs a text label", + }); + } + let outcome: Store.AddedOption | undefined; + let failure: unknown; + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) { + failure = new Error("implementation is active"); + return; + } + let record = plan.records.get(msg.id); + if (!record || !isOpenStatus(record.status)) { + outcome = { + ok: false, + reason: "closed", + message: "This decision is no longer open", + }; + return; + } + if (record.definition.questions.length !== 1) { + outcome = { + ok: false, + reason: "closed", + message: "Options cannot be added to this questionnaire", + }; + return; + } + let originalQuestions = plan.questions; + if (!Store.reserveOption(originalQuestions, msg.id)) { + outcome = Store.addOption(originalQuestions, msg.id, ulid(), msg.label); + return; + } + + let stagedDocument: room.Document | undefined; + 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.addOption(stagedQuestions, msg.id, ulid(), msg.label); + if (!outcome.ok) return; + stagedQuestions.open.get(msg.id)!.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: outcome.definition, + optionOrigins: { + ...record.optionOrigins, + [outcome.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: outcome.option.id, + label: outcome.option.label, + origin: "human", + }), + }; + let mutation = room.projectOptions( + stagedDocument, + msg.id, + outcome.definition.questions[0], + ); + await Service.publishStaged(plan, server, roomId, candidate, mutation); + } catch (err) { + if (err instanceof room.QuestionnaireProjectionError) { + outcome = { ok: false, reason: "closed", message: err.message }; + } else failure = err; + } finally { + Store.releaseOption(originalQuestions, msg.id); + stagedDocument?.doc.destroy(); + } + }); + if (failure || !outcome) { + console.error("[questions] could not add an option:", failure); + return fail( + ws, + msg.rid, + failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not add the option", + ); + } + if (!outcome.ok) { + return reply(ws, msg.rid, { kind: "question:add-option", ts: 0, id: msg.id, ...outcome }); + } + reply(ws, msg.rid, { + kind: "question:add-option", + ts: 0, + id: msg.id, + ok: true, + option: outcome.option, + revision: outcome.revision, + }); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id: msg.id, + definition: outcome.definition, + revision: outcome.revision, + }); + announce(plan, server, roomId, msg.id); + let record = plan.records.get(msg.id); + emit(plan, { + kind: "option-added", + id: msg.id, + ...(record?.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + optionId: outcome.option.id, + label: outcome.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..de5f5bb3 --- /dev/null +++ b/apps/server/src/questions/service-ask.ts @@ -0,0 +1,160 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; + +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"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +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 (plan.conversationPlan.threads.length + definition.questions.length > MAX_THREADS) { + throw new Error("conversation thread limit reached"); + } + let eventCount = definition.questions.reduce( + (count, question) => count + question.options.length + 2, + plan.conversationPlan.events.length, + ); + if (eventCount > MAX_EVENTS) throw new Error("conversation event limit reached"); + 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(); + let threadId = ulid(); + let at = Math.floor(Date.now() / 1_000); + 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(plan.questions, id, single, id); + let value = { + id, + 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, + history: [], + optionOrigins: {}, + editors: [], + }; + 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] }; + }); + plan.conversationPlan = conversationPlan; + + 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) { + 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..981c0ee4 --- /dev/null +++ b/apps/server/src/questions/service-cancel.ts @@ -0,0 +1,73 @@ +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"; + +// Current-main cancellation API and body retained unchanged. +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, + }); +} 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..7a3af2fb --- /dev/null +++ b/apps/server/src/questions/service-discard.ts @@ -0,0 +1,139 @@ +import * as Y from "yjs"; + +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 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..e375158a --- /dev/null +++ b/apps/server/src/questions/service-reopen.ts @@ -0,0 +1,212 @@ +import { rebase } from "./service-relationships"; +import { parse } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +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 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..3da1e411 --- /dev/null +++ b/apps/server/src/questions/service-submit.ts @@ -0,0 +1,220 @@ +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import { activeSettleDeferral } from "../conversation-plan/preference"; + +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 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 42205637..f763c7f2 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -12,27 +12,17 @@ * 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"; -import type { Record } from "./records"; +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, @@ -40,484 +30,28 @@ export { questionMentionsOption, } from "./records"; export type { DecisionEntry, OptionOrigin, Record } from "./records"; - -export type { Questions } from "./store"; - -export type AskPlacement = { - revision: number; - blocks: Array>; -}; +export { addOption } from "./service-add-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() })), - })), - }; -} - -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", - origin: "planner", - history: [], - optionOrigins: {}, - editors: [], - }; - 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, - }); -} - -/** - * 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index c5522ea3..0c2bd44f 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -545,3 +545,67 @@ Next: actual Chat job FIFO and saved-message hook integration, complete card eff consumers and remaining scoped job tools. These components are now verified separately, but live conversation-to-Planner wiring is still required for the requested backend milestone. Remaining whole pipeline callbacks stay assigned to their implementation groups. + +The integrated backend now binds saved room messages to the actual conversation +processor and interpreter, durable sidecar and outbox, card consumers and current +Chat/Harness Planner runtime. Acceptance persists the transcript and queued work +before acknowledgement or publication. Correction, scoped-save and explicit retry +commands use the authenticated member context and existing write/archive guards. +Configuration enables this path only with `CONVERSATION_PLAN=on`; no application +instance was activated during extraction. + +Actual MemoryStorage integrations exercise card creation, linking, receipts and +close/open recovery through the real runtime factory. Heading, refinement, +suggestions and prose jobs execute through the current Harness dispatch and actual +tools. Their profiles offer read tools plus their own writer; streamed foreign +writers fail the live boundary. Ordinary Planner turns gain the intended +`revise_open_decision` tool. Existing Copilot and Pi adapters, ownership and credential +boundaries remain. The prior unsupported-prose constructor test now checks an unknown +job kind because prose is supported. + +All 62 original card-consumer cases remain. Question durability retains 28 additional +service callbacks, 18 prose callbacks and six option-source callbacks, alongside +seven previously retained originals and all nine current service cases. Scoped tools +retain 57 original cases and seven byte-identical write-prose tests. Chat retains +16 complete original callbacks. Private test bridges adapt old fixture calls to the +real current AI SDK tools; no production compatibility adapter was added. + +Original prose tests exposed missing relationship metadata, empty-caret handling +and rebasing against the wrong source. The exact archived room projection, +reconciliation and restoration functions and Plan relationship snapshots are restored. +Server rewrites explicitly pass the pre-edit source when rebasing. Existing canonical +validation and unrelated current room and Plan functions remain source-proved. + +Independent review reproduced a post-close card commit while an effect batch was +held in storage. The processor now exposes an additive idle drain, and runtime stop +waits for effects, Planner cleanup and card mirroring before document close. Runtime +replacement also waits for that drain. A second review reproduced overlapping stop +calls resolving prematurely; repeated calls now share the same drain. Held-commit +regressions verify both shutdown and replacement against actual card consumers. +Cancellation clears live Planner write scope immediately; interrupted jobs are durable +and are not replayed automatically after reopening. + +A combined run exposed cached test-fixture cleanup hooks leaving Chat linger timers +alive and contaminating later Pi cases. Registering the unchanged cleanup hooks in +each split job suite fixes the fixture isolation; production timers and presence +handling were not changed. The combined Chat/Pi check then passes 145 cases. + +This milestone verifies backend behavior with offline interpreter answers, the current +Harness using deterministic test streams, and MemoryStorage. It does not establish +paid-model quality, PostgreSQL deployment behavior or browser behavior. No paid +inference, database migration, application restart or evaluation dataset import ran. +Frontend and research command integration and the remaining 122 assigned whole +pipeline callbacks remain later extraction work; the larger product goal is unfinished. + +The broader backend run also caught two existing synthetic fixture assumptions: an +exact processor API list omitted the additive idle method, and an answered decision +had no options, which the restored canonical record validator rejects. Only those +fixtures are corrected; dependency-capture, rewrite, anchor and comment assertions +and production validation remain intact. + +Final integrated backend, Harness, dialect, question and protocol verification passes +1,935 tests across 294 files with 9,388 assertions and no failures. The two PostgreSQL +suites are explicitly skipped without a test database. Workspace and E2E TypeScript +checks pass. CI passes with only two inherited lint warnings and the existing design +baseline finding. All seven runtime, processor, card, question, scoped-tool, Chat and +configuration source-parity checks pass after formatting. diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index d31c0b51..783ba667 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -122,6 +122,7 @@ export type Incoming = | Session.Incoming | import("./chat").Chat.Incoming | import("./comment").Comment.Incoming + | import("./conversation-plan").ConversationPlan.Incoming | import("./job").Job.Incoming | import("./plan").Plan.Incoming | import("./question").Question.Incoming; @@ -131,6 +132,7 @@ export type Outgoing = | Session.Outgoing | import("./chat").Chat.Outgoing | import("./comment").Comment.Outgoing + | import("./conversation-plan").ConversationPlan.Outgoing | import("./job").Job.Outgoing | import("./plan").Plan.Outgoing | import("./question").Question.Outgoing diff --git a/packages/protocol/plan.d.ts b/packages/protocol/plan.d.ts index d8dfd2ec..439f2591 100644 --- a/packages/protocol/plan.d.ts +++ b/packages/protocol/plan.d.ts @@ -46,6 +46,15 @@ export declare namespace Plan { digest: string; /** Kept rather than guessed at when neither position nor digest resolves. */ orphaned?: true; + /** A unique disappeared prose block may recover on the next document edit only. */ + recoverOnNextEdit?: true; + }; + + /** The editable paragraph a decided card became, kept even when its block is lost. */ + export type ProseAnchors = { + widget: string; + anchors: Anchor[]; + orphaned: boolean; }; /** Why a relationship is not currently trustworthy. */ @@ -133,6 +142,7 @@ export declare namespace Plan { epoch: string; widgets: WidgetAnchors[]; threads: ThreadAnchors[]; + prose?: ProseAnchors[]; }; /** @@ -231,6 +241,7 @@ export declare namespace Plan { anchors: WidgetAnchors[]; /** Which prose each comment thread marks, on the same terms. */ threads: ThreadAnchors[]; + prose?: ProseAnchors[]; limits: Limits; }; } From 5d6d0d44c89b416ef7cd60388a345d39ba9ac2a4 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:04:29 +0100 Subject: [PATCH 039/152] Integrate durable accepted research and drain admitted commands before close --- .../accepted-research-close.test.ts | 76 +++++ .../accepted-research-commit.test.ts | 63 ++++ .../accepted-research-gates.test.ts | 79 +++++ .../accepted-research-memory.test.ts | 80 +++++ .../accepted-research-socket.test-fixtures.ts | 41 +++ ...accepted-research-storage.test-fixtures.ts | 55 +++ .../accepted-research.test-fixtures.ts | 135 ++++++++ .../conversation-plan/accepted-research.ts | 79 +++++ .../pipeline-linked-card-resolution.test.ts | 107 ++++++ .../pipeline-linked-card.test.ts | 68 ++++ .../pipeline-ordinary-save-competing.test.ts | 125 +++++++ ...ine-ordinary-save-pending.test-fixtures.ts | 60 ++++ .../pipeline-ordinary-save.test-fixtures.ts | 140 ++++++++ .../pipeline-ordinary-save.test.ts | 134 ++++++++ .../conversation-plan/research-commands.ts | 108 ++++++ .../conversation-plan/runtime-save.test.ts | 186 +++++++++++ apps/server/src/conversation-plan/service.ts | 33 +- apps/server/src/main.ts | 191 ++++++++--- .../src/plan/research-placement-1.test.ts | 39 +++ .../src/plan/research-placement-2.test.ts | 57 ++++ .../plan/research-placement.test-fixtures.ts | 24 ++ apps/server/src/plan/room.ts | 10 + apps/server/src/plan/service.ts | 26 ++ apps/server/src/research/inline-1.test.ts | 136 ++++++++ apps/server/src/research/inline-2.test.ts | 135 ++++++++ apps/server/src/research/inline-3.test.ts | 139 ++++++++ apps/server/src/research/inline-4.test.ts | 64 ++++ .../server/src/research/inline-memory.test.ts | 90 +++++ .../src/research/inline.test-fixtures.ts | 77 +++++ .../research/placement-close.test-fixtures.ts | 164 +++++++++ .../src/research/placement-close.test.ts | 22 ++ apps/server/src/research/placement.test.ts | 57 ++++ apps/server/src/research/placement.ts | 30 ++ apps/server/src/research/service.test.ts | 33 +- apps/server/src/research/service.ts | 284 +++++++++++++--- apps/server/src/research/start-identity.ts | 49 +++ apps/server/src/socket/authorization.test.ts | 60 ++++ apps/server/src/socket/authorization.ts | 26 ++ apps/server/src/storage/contract.ts | 26 +- apps/server/src/storage/memory/adapter.ts | 17 +- apps/server/src/storage/memory/research.ts | 88 ++++- apps/server/src/storage/model.ts | 55 +++ apps/server/src/storage/port.ts | 28 +- .../src/storage/postgres/adapter.test.ts | 133 ++++++++ apps/server/src/storage/postgres/adapter.ts | 79 +++++ .../server/src/storage/postgres/migrations.ts | 3 + .../015_planner_inline_reference.sql | 7 + apps/server/src/storage/postgres/research.ts | 119 ++++++- .../src/storage/postgres/startup.test.ts | 312 +++++++++++++++++- .../src/storage/research-inline-contract-1.ts | 124 +++++++ .../src/storage/research-inline-contract-2.ts | 51 +++ .../src/storage/research-inline-contract-3.ts | 44 +++ .../src/storage/research-inline-contract-4.ts | 53 +++ .../src/storage/research-inline-contract-5.ts | 87 +++++ .../src/storage/research-inline-contract-6.ts | 39 +++ .../research-inline-contract-fixtures.ts | 23 ++ .../src/storage/research-inline-contract.ts | 16 + docs/jev-product-extraction.md | 52 +++ 58 files changed, 4478 insertions(+), 160 deletions(-) create mode 100644 apps/server/src/conversation-plan/accepted-research-close.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-commit.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-gates.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-memory.test.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-socket.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/accepted-research-storage.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/accepted-research.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/accepted-research.ts create mode 100644 apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-linked-card.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-ordinary-save-competing.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/pipeline-ordinary-save.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/pipeline-ordinary-save.test.ts create mode 100644 apps/server/src/conversation-plan/research-commands.ts create mode 100644 apps/server/src/conversation-plan/runtime-save.test.ts create mode 100644 apps/server/src/plan/research-placement-1.test.ts create mode 100644 apps/server/src/plan/research-placement-2.test.ts create mode 100644 apps/server/src/plan/research-placement.test-fixtures.ts create mode 100644 apps/server/src/research/inline-1.test.ts create mode 100644 apps/server/src/research/inline-2.test.ts create mode 100644 apps/server/src/research/inline-3.test.ts create mode 100644 apps/server/src/research/inline-4.test.ts create mode 100644 apps/server/src/research/inline-memory.test.ts create mode 100644 apps/server/src/research/inline.test-fixtures.ts create mode 100644 apps/server/src/research/placement-close.test-fixtures.ts create mode 100644 apps/server/src/research/placement-close.test.ts create mode 100644 apps/server/src/research/placement.test.ts create mode 100644 apps/server/src/research/placement.ts create mode 100644 apps/server/src/research/start-identity.ts create mode 100644 apps/server/src/socket/authorization.test.ts create mode 100644 apps/server/src/socket/authorization.ts create mode 100644 apps/server/src/storage/postgres/migrations/015_planner_inline_reference.sql create mode 100644 apps/server/src/storage/research-inline-contract-1.ts create mode 100644 apps/server/src/storage/research-inline-contract-2.ts create mode 100644 apps/server/src/storage/research-inline-contract-3.ts create mode 100644 apps/server/src/storage/research-inline-contract-4.ts create mode 100644 apps/server/src/storage/research-inline-contract-5.ts create mode 100644 apps/server/src/storage/research-inline-contract-6.ts create mode 100644 apps/server/src/storage/research-inline-contract-fixtures.ts create mode 100644 apps/server/src/storage/research-inline-contract.ts 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/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-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/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/runtime-save.test.ts b/apps/server/src/conversation-plan/runtime-save.test.ts new file mode 100644 index 00000000..143a48f5 --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-save.test.ts @@ -0,0 +1,186 @@ +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:add-option", + 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/service.ts b/apps/server/src/conversation-plan/service.ts index 535815b1..a7d08807 100644 --- a/apps/server/src/conversation-plan/service.ts +++ b/apps/server/src/conversation-plan/service.ts @@ -17,6 +17,7 @@ export function createProcessor(deps: Dependencies) { 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)); @@ -79,23 +80,33 @@ export function createProcessor(deps: Dependencies) { controller.abort(); } - /** Wait for a stopped processor's already-started effects before closing its document. */ + 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 task = running; - while (task) { - await task; - task = running; + let tasks = [...commands, ...(running ? [running] : [])]; + while (tasks.length > 0) { + await Promise.allSettled(tasks); + tasks = [...commands, ...(running ? [running] : [])]; } } return { - accept, + accept: track(accept), afterMessage, - correct, - saveScopedChoice, - researchConsent, - record, - retry, + correct: track(correct), + saveScopedChoice: track(saveScopedChoice), + researchConsent: track(researchConsent), + record: track(record), + retry: track(retry), setEffects, wake, stop, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 90f5f43e..a6debe74 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,6 +18,8 @@ import { CHAT_CAPABILITIES, incomingFrame } from "./chat/incoming"; import { ReferenceService } from "./chat/references"; import { createConversationRuntime } from "./conversation-plan/runtime"; 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"; @@ -39,9 +41,11 @@ import * as Inject from "./questions/inject"; import * as Marks from "./comments/inject"; import * as Questions from "./questions/service"; 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"; @@ -72,6 +76,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; @@ -83,6 +88,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; @@ -126,6 +133,58 @@ function presence(server: Server, room: Rooms.Room): void { }); } +function documentBackend(): Service.Backend { + return { + storage, + lease: () => { + if (!heldLease) throw new Error("storage writer lease is unavailable"); + return heldLease; + }, + fatal: err => { + console.error("chopin: plan persistence failed -", err); + signal(); + }, + onDocumentPersisted: target => 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. * @@ -144,18 +203,7 @@ async function plan(room: Rooms.Room, server: Server): Promise { - if (!heldLease) throw new Error("storage writer lease is unavailable"); - return heldLease; - }, - fatal: err => { - console.error("chopin: plan persistence failed -", err); - signal(); - }, - onDocumentPersisted: target => summaryCoordinator?.schedule(target), - }; + 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; @@ -205,14 +253,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, @@ -346,6 +403,27 @@ async function receive(ws: Socket, raw: string): Promise { }); 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; @@ -446,28 +524,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( @@ -699,6 +766,8 @@ 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); @@ -877,6 +946,11 @@ 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); @@ -982,6 +1056,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 { @@ -1122,6 +1222,7 @@ researchService = new ResearchWorkspaceService({ }, current: currentDocumentTarget, publish: announceResearchChanged, + terminalNotice: announceResearchTerminal, }); referenceService = new ReferenceService({ storage, @@ -1260,10 +1361,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/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/room.ts b/apps/server/src/plan/room.ts index 23b125d0..6d07940f 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,6 +20,7 @@ import * as Y from "yjs"; import { $createDecisionNode, $createPlanNodes, + $createResearchNode, $exportPlanTree, $importPlan, assert, @@ -29,6 +30,7 @@ import { PlanValidationError, QuestionnaireNode, registry as buildRegistry, + ResearchNode, serialize, ulid, } from "@chopin/dialect"; @@ -1070,3 +1072,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 47adc985..903d6b31 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1767,3 +1767,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/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 e2adc311..4aa154f0 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"; @@ -345,6 +347,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("-", "")}`; @@ -464,6 +596,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/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 4e3d9493..99acf0ea 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(); }); + + 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 0c2bd44f..55a799d0 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -609,3 +609,55 @@ suites are explicitly skipped without a test database. Workspace and E2E TypeScr checks pass. CI passes with only two inherited lint warnings and the existing design baseline finding. All seven runtime, processor, card, question, scoped-tool, Chat and configuration source-parity checks pass after formatting. + +Research consent now reaches the current server runtime. Two whole original socket +command cases retain writer/archive checks, authenticated consent and forced access +refresh for observational link lookup. The accepted-offer executor rechecks the live +requester and current process owner before start and again before enqueue. Existing +Chat research creation now uses the same durable inline placement path. Explicit +startup and restore recovery retain pending-reference identity; shutdown awaits an +in-flight recovery before closing documents. Reads do not launch recovery or work. + +Research service and storage preserve 279 archived declarations, including 258 +unchanged ancestor declarations. Nineteen additional whole original service/storage +callbacks and all original helper/table data remain. Both providers validate Research +projections inside the fenced document commit. Migration 015 and the original +PostgreSQL race/startup regressions are retained as source only; no migration or +PostgreSQL test ran. Four complete original Plan placement callbacks exercise held, +failed and repeated commits and the implementation guard. All 137 existing Plan/room +declarations remain unchanged alongside the two original placement functions. + +Eleven new integrations connect the actual processor, consent executor, current owner +resolution, real issued sessions, ResearchWorkspaceService, JobService, Plan placement +and MemoryStorage. They verify frozen consent identity, reference and placed-marker +commit before enqueue, held/failed publication, idempotent consent and lookup, actual +owner revocation between placement and enqueue, resume by a second authenticated +member without changing the original actor, and writer/archive/read-access gates. +No research worker or model provider executes in these tests. + +Nine further whole linked-card and ordinary-Save pipeline callbacks are retained unchanged. Pipeline +ownership is now 27 of 140 callback nodes; 113 remain assigned to later slices. A new +actual runtime test separately verifies conversation interpretation, a linked human +option, advisory prompt, human Save, commit-before-acknowledgement, card mirroring and +close/open recovery. Bypassing its held commit makes the durable-state assertion fail. +This test does not change the retained CP-063/D19 policy behavior. + +Independent review reproduced two shutdown races through the actual current runtime. +A research host tool could wait behind the document lock held by close while close +waited for Chat. Placement now checks room closing before requesting that lock and +again inside it, and refuses already-closing Plan persistence. The real Harness +regression verifies close completes, the same durable request remains pending and +no evidence job is queued. + +A consent command could also commit its delivery receipt after processor idle and +Plan close. A separate in-flight set now tracks public asynchronous commands without +changing their original bodies, awaits or returned promises. Idle drains commands +and the existing effects loop until both settle. The held-callback regression verifies +the receipt commits before close and survives reopening, with no after-close commit. +Both original failures were reproduced before the fixes; independent review approves +these narrow lifecycle changes. All 29 original processor awaits remain intact. + +Fresh focused lifecycle verification passes eight tests with 42 assertions. The +combined checkpoint also passes workspace and E2E types and CI. PostgreSQL +regressions remain source-only; runtime coverage uses MemoryStorage and offline +Harness streams. No application instance, paid model or evaluation runner ran. From 309a6a44d51b1db2fd403e5b69d83a52707c902a Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:05:34 +0100 Subject: [PATCH 040/152] Restore Conversation Save controls and shared questionnaire state --- apps/web/package.json | 1 + apps/web/src/chat/chat.tsx | 9 +- apps/web/src/chat/decision-entry.test.tsx | 267 +++++++++ apps/web/src/chat/decision-entry.tsx | 222 ++++++++ apps/web/src/chat/model.test.ts | 15 + apps/web/src/chat/model.ts | 10 +- .../web/src/chat/scoped-choice-entry.test.tsx | 156 ++++++ apps/web/src/chat/scoped-choice-entry.tsx | 141 +++++ .../transcript-decisions.test-fixtures.ts | 214 ++++++++ .../src/chat/transcript-decisions.test.tsx | 50 ++ .../chat/transcript-scoped-choice.test.tsx | 75 +++ apps/web/src/chat/transcript.tsx | 111 +++- apps/web/src/conversation-plan/links.test.ts | 179 ++++++ apps/web/src/conversation-plan/links.ts | 97 ++++ apps/web/src/conversation-plan/store.test.ts | 78 +++ apps/web/src/conversation-plan/store.ts | 68 +++ apps/web/src/room-workspace.tsx | 30 + bun.lock | 1 + docs/jev-product-extraction.md | 42 ++ packages/editor/src/card-meta.test.ts | 110 ++++ packages/editor/src/card-meta.ts | 93 ++++ packages/editor/src/decision-state.test.ts | 105 +++- packages/editor/src/decision-state.ts | 25 +- packages/editor/src/index.ts | 2 + packages/editor/src/provider.test.ts | 83 ++- packages/editor/src/provider.ts | 28 +- packages/editor/src/questionnaires.test.ts | 177 +++++- packages/editor/src/questionnaires.ts | 78 ++- packages/icons/src/index.ts | 1 + packages/icons/src/line.tsx | 11 + packages/question/src/react/index.ts | 2 + .../src/react/project-suggestion.test.ts | 47 ++ .../question/src/react/project-suggestion.ts | 36 ++ .../src/react/questionnaire-controller.ts | 513 ++++++++++++++++++ .../question/src/react/questionnaire-types.ts | 42 ++ .../react/use-questionnaire-cancel.test.ts | 68 +++ .../react/use-questionnaire-lifecycle.test.ts | 83 +++ .../use-questionnaire-option-growth.test.ts | 138 +++++ .../use-questionnaire-refresh-save.test.ts | 133 +++++ .../src/react/use-questionnaire-save.test.ts | 136 +++++ .../react/use-questionnaire.test-fixtures.ts | 116 ++++ .../src/react/use-questionnaire.test.ts | 13 +- .../question/src/react/use-questionnaire.ts | 423 +-------------- .../exceptions/dynamic-packages.json | 9 +- 44 files changed, 3791 insertions(+), 447 deletions(-) create mode 100644 apps/web/src/chat/decision-entry.test.tsx create mode 100644 apps/web/src/chat/decision-entry.tsx create mode 100644 apps/web/src/chat/scoped-choice-entry.test.tsx create mode 100644 apps/web/src/chat/scoped-choice-entry.tsx create mode 100644 apps/web/src/chat/transcript-decisions.test-fixtures.ts create mode 100644 apps/web/src/chat/transcript-decisions.test.tsx create mode 100644 apps/web/src/chat/transcript-scoped-choice.test.tsx create mode 100644 apps/web/src/conversation-plan/links.test.ts create mode 100644 apps/web/src/conversation-plan/links.ts create mode 100644 apps/web/src/conversation-plan/store.test.ts create mode 100644 apps/web/src/conversation-plan/store.ts create mode 100644 packages/editor/src/card-meta.test.ts create mode 100644 packages/editor/src/card-meta.ts create mode 100644 packages/question/src/react/project-suggestion.test.ts create mode 100644 packages/question/src/react/project-suggestion.ts create mode 100644 packages/question/src/react/questionnaire-controller.ts create mode 100644 packages/question/src/react/questionnaire-types.ts create mode 100644 packages/question/src/react/use-questionnaire-cancel.test.ts create mode 100644 packages/question/src/react/use-questionnaire-lifecycle.test.ts create mode 100644 packages/question/src/react/use-questionnaire-option-growth.test.ts create mode 100644 packages/question/src/react/use-questionnaire-refresh-save.test.ts create mode 100644 packages/question/src/react/use-questionnaire-save.test.ts create mode 100644 packages/question/src/react/use-questionnaire.test-fixtures.ts diff --git a/apps/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 dd851054..698eb81b 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -35,10 +35,11 @@ import { reviseComposerDraft, } from "./references"; import { Transcript } from "./transcript"; +import type { TranscriptDecisions } from "./transcript"; 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 { ComposerDraft, ReferenceTarget } from "./references"; import type { Wire as Socket } from "../wire"; @@ -55,6 +56,8 @@ export type ChatProps = { agent?: boolean; active?: boolean; onActivity?: (event: { type: "message" | "working"; busy: boolean }) => void; + conversationPlan?: ConversationPlan.State; + decisions?: TranscriptDecisions; }; export function Chat( @@ -64,6 +67,8 @@ export function Chat( connected, handle, onActivity, + conversationPlan, + decisions, referencesEnabled, repository, room, @@ -275,6 +280,8 @@ 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/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/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.tsx b/apps/web/src/chat/transcript.tsx index 002dca7b..9a079213 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -1,17 +1,36 @@ /** The shared chat, grouped for reading rather than event delivery. */ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { ChevronIcon, CloseIcon, LoaderIcon, SignInIcon } from "@chopin/icons"; -import { AgentFace, Face, MotionDisclosure, MotionDisclosureIcon } from "@chopin/editor"; +import { + AgentFace, + Face, + MotionDisclosure, + MotionDisclosureIcon, + useCardMeta, +} from "@chopin/editor"; import { MessageMarkdown } from "./markdown"; import { capitalize, displayText, duration, group, summarize, toolCopy } from "./model"; import { motionContract } from "../motion-contract"; import { motionImmediately } from "../motion-input"; -import type { Chat } from "@chopin/protocol"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; import type { Group, Message } from "./model"; +import type { CardMetaStore, QuestionnaireStore } from "@chopin/editor"; +import type { Transport } from "@chopin/question/react"; +import { ActivityLine, DecisionPrompt } from "./decision-entry"; +import { ScopedChoicePrompt } from "./scoped-choice-entry"; + +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" }); @@ -105,6 +124,59 @@ function SystemEntry({ item }: { item: Extract }) { ); } +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: string; @@ -189,6 +261,8 @@ function MessageGroup( export function Transcript( { active, + conversationPlan, + decisions, entries, handle, onWithdraw, @@ -196,6 +270,9 @@ export function Transcript( working, }: { active: boolean; + canEdit?: boolean; + conversationPlan?: ConversationPlan.State; + decisions?: TranscriptDecisions; entries: Chat.Entry[]; handle: string; onWithdraw: (id: string) => void; @@ -206,6 +283,16 @@ export function Transcript( let bottom = useRef(null); let pinned = useRef(true); 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" }); @@ -227,7 +314,23 @@ 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} + /> + ) + : : ( ({ + messageId, + author: { kind: "member", handle: "ana" }, + quote: "x", + start: 0, + end: 1, + role, +}); + +let state = { + schemaVersion: 1, + revision: 3, + queue: [], + analysis: [], + threads: [{ + id: "t1", + question: "q", + questionSources: [source("m1", "question")], + questionAuthoring: "quoted", + status: "reopened", + contributions: [ + { + id: "o1", + kind: "option", + text: "proposal", + authoring: "quoted", + sources: [source("m2", "option")], + actor: { kind: "classifier" }, + }, + { + id: "r1", + kind: "reason", + text: "reason", + authoring: "quoted", + sources: [source("m3", "reason")], + actor: { kind: "classifier" }, + }, + { + id: "c1", + kind: "constraint", + text: "constraint", + authoring: "quoted", + sources: [source("m4", "constraint")], + actor: { kind: "classifier" }, + }, + ], + stances: [ + { + id: "s1", + participant: "bo", + position: "support", + sources: [source("m5", "support")], + at: 1, + }, + { + id: "s2", + participant: "cy", + position: "oppose", + sources: [source("m6", "objection")], + at: 2, + }, + { + id: "s3", + participant: "di", + position: "neutral", + sources: [source("m7", "support")], + at: 3, + }, + ], + stanceHistory: [], + decision: { + id: "d1", + text: "decision", + sources: [source("m8", "resolution")], + actor: { kind: "member", handle: "ana" }, + at: 4, + }, + decisionHistory: [], + candidates: [ + { + id: "ca1", + kind: "resolution", + text: "resolution", + sources: [source("m9", "resolution")], + status: "pending", + }, + { + id: "ca2", + kind: "reopening", + text: "reopen", + sources: [source("m10", "reopening")], + status: "pending", + }, + ], + questionnaireId: "Q", + version: 3, + }], + events: [ + { + id: "e1", + type: "settle.suggested", + threadId: "t1", + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source("m11", "resolution"), + optionId: "o1", + }, + { + id: "e2", + type: "decision.reopened", + threadId: "t1", + observedThreadVersion: 2, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 2, + explicit: true, + source: source("m12", "reopening"), + }, + { + id: "e3", + type: "thread.leaning", + threadId: "t1", + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source("m13", "resolution"), + }, + ], +} as unknown as ConversationPlan.State; + +test("threadForCard finds the linked thread", () => { + expect(threadForCard(state, "Q")?.id).toBe("t1"); + expect(threadForCard(state, "nope")).toBeUndefined(); +}); + +test("a prose job belongs to the linked question's opening message", () => { + let prose = { + id: "prose:Q:decided:Q:2", + kind: "prose", + target: "Q", + trigger: "decided:Q:2", + status: "failed", + attempts: 1, + reason: "Writer failed", + at: "2026-09-25T10:00:00.000Z", + } as ConversationPlan.Job; + expect(jobsForMessage(state, [prose], "m1")).toEqual([prose]); + expect(jobsForMessage(state, [prose], "m2")).toEqual([]); + expect(jobsForMessage(state, [{ ...prose, target: "other" }], "m1")).toEqual([]); +}); + +test("badges name accepted roles only", () => { + let labels = (messageId: string) => messageLinks(state, messageId).map(link => link.label); + + expect(labels("m1")).toEqual(["Question"]); + expect(labels("m2")).toEqual(["Proposal"]); + expect(labels("m3")).toEqual(["Reason"]); + expect(labels("m4")).toEqual(["Constraint"]); + expect(labels("m5")).toEqual(["Support"]); + expect(labels("m6")).toEqual(["Objection"]); + expect(labels("m7")).toEqual([]); + expect(labels("m8")).toEqual([]); + expect(labels("m9")).toEqual([]); + expect(labels("m10")).toEqual(["Reopening"]); + expect(labels("m11")).toEqual(["Settle"]); + expect(labels("m12")).toEqual(["Reopening"]); + expect(labels("m13")).toEqual([]); +}); diff --git a/apps/web/src/conversation-plan/links.ts b/apps/web/src/conversation-plan/links.ts new file mode 100644 index 00000000..e5d35bc1 --- /dev/null +++ b/apps/web/src/conversation-plan/links.ts @@ -0,0 +1,97 @@ +import type { ConversationPlan } from "@chopin/protocol"; + +export type CardLink = { + threadId: string; + itemId: string; + label: string; + source: ConversationPlan.SourceRef; +}; + +/** Resolve against the current projection, so moved contributions keep useful backlinks. */ +export function messageLinks(state: ConversationPlan.State, messageId: string): CardLink[] { + let links: CardLink[] = []; + let add = ( + threadId: string, + itemId: string, + label: string, + sources: ConversationPlan.SourceRef[], + ) => { + for (let source of sources) { + if (source.messageId === messageId) links.push({ threadId, itemId, label, source }); + } + }; + for (let thread of state.threads) { + add(thread.id, thread.id, "Question", thread.questionSources); + for (let item of thread.contributions) { + let label = item.kind === "option" ? "Proposal" : item.kind === "reason" + ? "Reason" + : "Constraint"; + add(thread.id, item.id, label, item.sources); + } + for (let stance of [...thread.stances, ...thread.stanceHistory]) { + let label = stance.position === "support" + ? "Support" + : stance.position === "oppose" + ? "Objection" + : undefined; + if (label) add(thread.id, stance.id, label, stance.sources); + } + for (let candidate of thread.candidates) { + if (candidate.kind === "reopening") { + add(thread.id, candidate.id, "Reopening", candidate.sources); + } + } + } + for (let event of state.events) { + if (event.type !== "settle.suggested" && event.type !== "decision.reopened") continue; + if (event.source?.messageId !== messageId) continue; + let thread = state.threads.find(item => item.id === event.threadId); + if (thread) { + add(thread.id, thread.id, event.type === "decision.reopened" ? "Reopening" : "Settle", [ + event.source, + ]); + } + } + return links.filter((link, index) => + links.findIndex(other => + other.itemId === link.itemId && other.source.start === link.source.start + && other.source.end === link.source.end + ) === index + ); +} + +export function threadForCard( + state: ConversationPlan.State, + questionnaireId: string, +): ConversationPlan.Thread | undefined { + return state.threads.find(thread => thread.questionnaireId === questionnaireId); +} + +/** Prose jobs retain a generation trigger; show them beside the opening question. */ +export function jobsForMessage( + state: ConversationPlan.State, + jobs: readonly ConversationPlan.Job[], + messageId: string, +): ConversationPlan.Job[] { + return jobs.filter(job => + job.trigger === messageId + || job.kind === "prose" + && threadForCard(state, job.target)?.questionSources[0]?.messageId === messageId + ); +} + +export function analysisForMessage(state: ConversationPlan.State, messageId: string) { + let record = state.analysis.find(item => item.messageId === messageId); + if (record) return record; + let queued = state.queue.find(item => item.messageId === messageId); + if (!queued) return undefined; + return { + messageId, + status: queued.status === "failed" ? "failed" : "queued", + questionSetVersion: "", + modelVersion: "", + passes: [], + eventIds: [], + error: queued.error, + } satisfies ConversationPlan.AnalysisRecord; +} diff --git a/apps/web/src/conversation-plan/store.test.ts b/apps/web/src/conversation-plan/store.test.ts new file mode 100644 index 00000000..72f9a2ce --- /dev/null +++ b/apps/web/src/conversation-plan/store.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; + +import { ConversationPlanStore } from "./store"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Wire } from "../wire"; + +function fixture() { + let listeners = new Map void>>(); + let wire = { + on( + kind: string, + listener: (frame: unknown) => void, + ) { + let group = listeners.get(kind) ?? new Set(); + group.add(listener); + listeners.set(kind, group); + return () => group.delete(listener); + }, + } as unknown as Wire; + let emit = ( + kind: "conversation-plan:snapshot" | "conversation-plan:changed", + revision: number, + jobs: ConversationPlan.Job[] = [], + ) => { + let state: ConversationPlan.State = { + schemaVersion: 1, + revision, + events: [], + threads: [], + queue: [], + analysis: [], + }; + for (let listener of listeners.get(kind) ?? []) { + listener({ kind, state, ts: 0, ...(kind === "conversation-plan:snapshot" ? { jobs } : {}) }); + } + }; + let emitJobs = (jobs: ConversationPlan.Job[]) => { + for (let listener of listeners.get("conversation-plan:jobs") ?? []) { + listener({ kind: "conversation-plan:jobs", jobs, ts: 0 }); + } + }; + return { emit, emitJobs, wire }; +} + +test("a changed frame can precede a stale snapshot; jobs replace and reset with the connection", () => { + let store = new ConversationPlanStore("room-a"); + expect(store.get()).toEqual({ enabled: false, jobs: [] }); + let first = fixture(); + let close = store.listen(first.wire); + first.emit("conversation-plan:changed", 2); + first.emit("conversation-plan:snapshot", 1); + expect(store.get()).toMatchObject({ enabled: true, state: { revision: 2 } }); + expect(store.get().jobs).toEqual([]); + close(); + expect(store.get()).toEqual({ enabled: false, jobs: [] }); + let second = fixture(); + let closeSecond = store.listen(second.wire); + expect(store.get()).toEqual({ enabled: false, jobs: [] }); + first.emit("conversation-plan:changed", 3); + expect(store.get()).toEqual({ enabled: false, jobs: [] }); + let job: ConversationPlan.Job = { + id: "refine:card-1:event-1", + kind: "refine", + target: "card-1", + trigger: "message-1", + status: "pending", + attempts: 1, + at: "2026-09-25T10:00:00.000Z", + }; + second.emit("conversation-plan:snapshot", 4, [job]); + expect(store.get()).toMatchObject({ enabled: true, state: { revision: 4 } }); + expect(store.get().jobs).toEqual([job]); + second.emitJobs([{ ...job, status: "done", output: "Updated the title." }]); + expect(store.get().jobs).toMatchObject([{ id: job.id, status: "done" }]); + closeSecond(); + expect(store.get()).toEqual({ enabled: false, jobs: [] }); +}); diff --git a/apps/web/src/conversation-plan/store.ts b/apps/web/src/conversation-plan/store.ts new file mode 100644 index 00000000..97ab8878 --- /dev/null +++ b/apps/web/src/conversation-plan/store.ts @@ -0,0 +1,68 @@ +import { useSyncExternalStore } from "react"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Wire } from "../wire"; + +type Snapshot = { enabled: boolean; state?: ConversationPlan.State; jobs: ConversationPlan.Job[] }; + +/** One room-owned subscription. A new socket connection cannot retain old cards. */ +export class ConversationPlanStore { + #snapshot: Snapshot = { enabled: false, jobs: [] }; + #listeners = new Set<() => void>(); + #room: string; + + constructor(room: string) { + this.#room = room; + } + + get = (): Snapshot => this.#snapshot; + subscribe = (listener: () => void): () => void => { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + }; + + reset(): void { + this.#snapshot = { enabled: false, jobs: [] }; + this.#emit(); + } + + listen(wire: Wire): () => void { + this.reset(); + let receive = (frame: ConversationPlan.Snapshot | ConversationPlan.Changed) => { + if (frame.state.revision < (this.#snapshot.state?.revision ?? -1)) return; + this.#snapshot = { + enabled: true, + state: frame.state, + jobs: frame.kind === "conversation-plan:snapshot" + ? frame.jobs ?? [] + : this.#snapshot.jobs, + }; + this.#emit(); + }; + let receiveJobs = (frame: ConversationPlan.Jobs) => { + this.#snapshot = { ...this.#snapshot, enabled: true, jobs: frame.jobs }; + this.#emit(); + }; + let off = [ + wire.on("conversation-plan:snapshot", receive), + wire.on("conversation-plan:changed", receive), + wire.on("conversation-plan:jobs", receiveJobs), + ]; + return () => { + for (let unsubscribe of off) unsubscribe(); + this.reset(); + }; + } + + get room(): string { + return this.#room; + } + + #emit(): void { + for (let listener of this.#listeners) listener(); + } +} + +export function useConversationPlan(store: ConversationPlanStore): Snapshot { + return useSyncExternalStore(store.subscribe, store.get, store.get); +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index c3c8a246..85f1c9f8 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -3,6 +3,7 @@ import { documentPath } from "@chopin/protocol/document-url"; import { ChevronIcon, DocumentIcon } from "@chopin/icons"; import { advanceDecisionView, + CardMetaStore, countUnanswered, cursor, Decisions, @@ -17,6 +18,7 @@ import { } from "@chopin/editor"; import { Chat } from "./chat/chat"; +import { ConversationPlanStore, useConversationPlan } from "./conversation-plan/store"; import { rememberChannel } from "./channel-recovery"; import { decisionAttention, DecisionViewControl } from "./decision-view-control"; import { newestDocumentMetadata } from "./document-actions"; @@ -164,6 +166,8 @@ export function RoomWorkspace( }: HostedWorkspaceProps, ) { let [wire, setWire] = useState(); + let conversationStore = useMemo(() => new ConversationPlanStore(room), [room]); + let conversation = useConversationPlan(conversationStore); let { onDocumentAction, onDocumentChanged, @@ -201,6 +205,7 @@ export function RoomWorkspace( let researchEnabled = profile.research; let [workspace, dispatch] = useWorkspaceState(profile); let [questions] = useState(() => new QuestionnaireStore()); + let [cardMeta] = useState(() => new CardMetaStore()); let [threads] = useState(() => new ThreadStore()); let research = useMemo( () => @@ -307,6 +312,12 @@ export function RoomWorkspace( dispatch({ type: "set-chat", open: false }); }; + let showDecisionCard = (questionnaireId: string) => { + let entry = questions.snapshot().find(item => item.id === questionnaireId); + let question = entry?.value.questions[0]?.id; + if (entry && question) showPlan(entry.id, question); + }; + let setDesktopChatOpen = (open: boolean) => { dispatch({ type: "set-desktop-chat", open }); if (!open) dispatch({ type: "set-chat", open: false }); @@ -354,6 +365,7 @@ export function RoomWorkspace( useEffect(() => () => research.reset(), [research]); useEffect(() => { + cardMeta.listen(undefined); let socket = new Wire({ channelId: room, onAuthenticationRequired: () => location.reload(), @@ -362,12 +374,17 @@ export function RoomWorkspace( onDocumentDeleted(room); }, onStatus: next => { + if (next === "connected") { + conversationStore.reset(); + cardMeta.listen(undefined); + } setStatus(next); }, }); setWire(socket); let off = [ + conversationStore.listen(socket), socket.on("session:hello", frame => { let editable = frame.canEdit && !frame.archivedAt; let accessChanged = latestCanEdit.current !== editable @@ -406,10 +423,12 @@ export function RoomWorkspace( if (researchEnabled) research.invalidate(frame.workspaceId); }), threads.listen(socket), + cardMeta.listen(socket), ]; return () => { for (let unsubscribe of off) unsubscribe(); + cardMeta.listen(undefined); socket.dispose(); setWire(undefined); }; @@ -422,6 +441,8 @@ export function RoomWorkspace( room, profile.surface, threads, + cardMeta, + conversationStore, updateMetadata, ]); @@ -444,6 +465,15 @@ export function RoomWorkspace( connected={status === "connected" && workspaceCanEdit} handle={handle} onActivity={onChatActivity} + conversationPlan={conversation.state} + decisions={{ + questions, + meta: cardMeta, + wire, + connected: status === "connected", + canEdit: workspaceCanEdit, + onOpenCard: showDecisionCard, + }} referencesEnabled={chatReferences.wire === wire && chatReferences.enabled} repository={repository} room={room} diff --git a/bun.lock b/bun.lock index 669bf6c7..641db85f 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "@chopin/editor": "workspace:*", "@chopin/icons": "workspace:*", "@chopin/protocol": "workspace:*", + "@chopin/question": "workspace:*", "@chopin/viewport": "workspace:*", "@chopin/visuals": "workspace:*", "@fontsource-variable/inter": "^5.3.0", diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 55a799d0..f1ee52a7 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -661,3 +661,45 @@ Fresh focused lifecycle verification passes eight tests with 42 assertions. The combined checkpoint also passes workspace and E2E types and CI. PostgreSQL regressions remain source-only; runtime coverage uses MemoryStorage and offline Harness streams. No application instance, paid model or evaluation runner ran. + +The original Conversation Save prompts now receive live questionnaire definitions, +card status/generation metadata and the room's conversation state. The restored +latest-prompt routing preserves ordinary message grouping, tools, composer, layout +and current authorization. Stores reset when the room or connection changes. Six +whole original transcript/model callbacks remain alongside current tests. + +The scoped prompt initially trusted the conversation's contribution summary as its +option list. Review reproduced a valid canonical card option missing from that +summary, which disabled Save. The prompt now checks the current card definition, +unique option ID and exact proposal label, while preserving every other original +proposal, version, generation, support and status guard and the complete Save body. +Twelve regressions cover valid options and missing, stale, renamed or duplicate +canonical options. The actual transcript passes the current card snapshot. + +Questionnaire transport preserves all original submit, shared draft, generation, +reopen and deferred teardown behavior, together with current cancellation. A +reproduced failed-cancel teardown leak is fixed by settling unobserved cleanup in +cancel's finalizer. The natural controller owns the private state; the public hook +keeps shared caches and existing exports. Suggestion projection is retained as a +pure leaf; the larger QuestionView and remaining card surface are later work. + +Editor metadata, unanswered counts, document-content checks and prose bindings +retain their complete original functions and callbacks. Existing editor geometry, +selection and display behavior remain. Save components use the current typography +roles. The original DecisionIcon follows the existing typed LineIcon boundary; +its one reviewed forwarding exception and owner hash are the only design-contract +manifest additions. The web package directly declares its new question dependency. + +This checkpoint covers server and pure/SSR browser-side behavior. PostgreSQL +regressions and migration 015 are retained but were not executed. No browser +session, database migration, application restart, paid inference or evaluation +runner was used. Remaining pipeline callbacks, QuestionView/card interactions, +conversation analysis and research-offer surfaces, and deployment configuration +remain extraction work. The overall product goal is unfinished. + +The combined server, web, dialect, question, protocol, editor and icon run passes +2,710 tests across 410 files with 12,639 assertions and no failures; two PostgreSQL +suites are explicitly skipped without a database. Workspace and E2E TypeScript +checks pass. CI passes with only two inherited lint warnings and the existing +design baseline finding. All eleven research, placement, processor, browser Save, +controller, editor and callback-preservation checks pass after formatting. diff --git a/packages/editor/src/card-meta.test.ts b/packages/editor/src/card-meta.test.ts new file mode 100644 index 00000000..e5f61515 --- /dev/null +++ b/packages/editor/src/card-meta.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "bun:test"; + +import { CardMetaStore } from "./card-meta"; + +import type { Transport } from "./transport"; + +function wire() { + let handlers = new Map void>(); + let value: Transport = { + on: (kind, handler) => { + handlers.set(kind, handler as never); + return () => handlers.delete(kind); + }, + send() {}, + ask: async () => ({}) as never, + }; + return { value, emit: (kind: string, frame: unknown) => handlers.get(kind)?.(frame as never) }; +} + +const META = { + status: "decided" as const, + origin: "conversation" as const, + involved: ["ana"], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, +}; + +describe("CardMetaStore", () => { + it("takes a join snapshot and later updates", () => { + let store = new CardMetaStore(); + let socket = wire(); + store.listen(socket.value); + socket.emit("question:metas", { cards: [{ id: "w", meta: META }] }); + expect(store.get("w")?.status).toBe("decided"); + socket.emit("question:meta", { id: "w", meta: { ...META, status: "discarded" } }); + expect(store.get("w")?.status).toBe("discarded"); + }); + + it("notifies subscribers with a new snapshot identity", () => { + let store = new CardMetaStore(); + let socket = wire(); + store.listen(socket.value); + let before = store.snapshot(); + let calls = 0; + store.subscribe(() => calls++); + socket.emit("question:meta", { id: "w", meta: META }); + expect(calls).toBe(1); + expect(store.snapshot()).not.toBe(before); + }); + + it("retires a closed controller before publishing reopened metadata", () => { + let forgotten: string[] = []; + let store = new CardMetaStore((_wire, id) => forgotten.push(id)); + let socket = wire(); + store.listen(socket.value); + socket.emit("question:meta", { id: "w", meta: META }); + store.subscribe(() => { + if (store.get("w")?.status === "reopened") expect(forgotten).toEqual(["w"]); + }); + socket.emit("question:meta", { id: "w", meta: { ...META, status: "reopened" } }); + expect(forgotten).toEqual(["w"]); + }); + + it("retires a closed controller from a reconnect snapshot only once", () => { + let forgotten: string[] = []; + let store = new CardMetaStore((_wire, id) => forgotten.push(id)); + let socket = wire(); + store.listen(socket.value); + socket.emit("question:metas", { cards: [{ id: "w", meta: META }] }); + let reopened = { ...META, status: "reopened" }; + socket.emit("question:metas", { cards: [{ id: "w", meta: reopened }] }); + expect(forgotten).toEqual(["w"]); + socket.emit("question:metas", { cards: [{ id: "w", meta: reopened }] }); + expect(forgotten).toEqual(["w"]); + }); + + it("recognizes a later reopen even when the decided frame was missed", () => { + let forgotten: string[] = []; + let store = new CardMetaStore((_wire, id) => forgotten.push(id)); + let socket = wire(); + store.listen(socket.value); + let first = { ...META, status: "reopened", history: [{ choices: ["a"], owner: "ana", at: 1 }] }; + let second = { + ...first, + history: [...first.history, { choices: ["b"], owner: "ben", at: 2 }], + }; + socket.emit("question:metas", { cards: [{ id: "w", meta: first }] }); + socket.emit("question:metas", { cards: [{ id: "w", meta: second }] }); + expect(forgotten).toEqual(["w", "w"]); + }); + + it("clears on disconnect without retiring an already reopened draft again", () => { + let forgotten: string[] = []; + let store = new CardMetaStore((_wire, id) => forgotten.push(id)); + let socket = wire(); + let off = store.listen(socket.value); + let reopened = { ...META, status: "reopened" }; + socket.emit("question:metas", { cards: [{ id: "w", meta: reopened }] }); + expect(forgotten).toEqual(["w"]); + off(); + store.listen(undefined); + expect(store.get("w")).toBeUndefined(); + store.listen(socket.value); + socket.emit("question:metas", { cards: [{ id: "w", meta: reopened }] }); + expect(forgotten).toEqual(["w"]); + }); +}); diff --git a/packages/editor/src/card-meta.ts b/packages/editor/src/card-meta.ts new file mode 100644 index 00000000..f6499d73 --- /dev/null +++ b/packages/editor/src/card-meta.ts @@ -0,0 +1,93 @@ +/** Server-owned state for decision cards, alongside their document nodes. */ + +import { forget } from "@chopin/question/react"; +import { useSyncExternalStore } from "react"; + +import type { Question } from "@chopin/protocol"; +import type { Transport as QuestionTransport } from "@chopin/question/react"; +import type { Transport } from "./transport"; + +type Forget = (wire: Transport, id: string) => void; + +const EMPTY: ReadonlyMap = new Map(); + +export class CardMetaStore { + #cards: ReadonlyMap = EMPTY; + #listeners = new Set<() => void>(); + #forget: Forget; + // A history entry is appended on each reopen. Remember that generation per + // transport so reconnect snapshots cannot retire the same live draft twice. + #retired = new WeakMap>(); + + constructor(onReopen: Forget = (wire, id) => forget(wire as unknown as QuestionTransport, id)) { + this.#forget = onReopen; + } + + subscribe = (listener: () => void): () => void => { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + }; + + snapshot = (): ReadonlyMap => this.#cards; + + get(id: string): Question.CardMeta | undefined { + return this.#cards.get(id); + } + + listen(wire: Transport | undefined): () => void { + if (!wire) { + this.#replace(EMPTY); + return () => {}; + } + let off = [ + wire.on("question:metas", frame => { + for (let { id, meta } of frame.cards) this.#retire(wire, id, meta); + this.#replace(new Map(frame.cards.map(card => [card.id, card.meta]))); + }), + wire.on("question:meta", frame => { + this.#retire(wire, frame.id, frame.meta); + let next = new Map(this.#cards); + next.set(frame.id, frame.meta); + this.#replace(next); + }), + ]; + return () => { + for (let stop of off) stop(); + }; + } + + #retire(wire: Transport, id: string, meta: Question.CardMeta): void { + if (meta.status !== "reopened") return; + let generations = this.#retired.get(wire); + if (!generations) this.#retired.set(wire, generations = new Map()); + let generation = meta.history.length; + if (generations.get(id) === generation) return; + generations.set(id, generation); + this.#forget(wire, id); + } + + #replace(cards: ReadonlyMap): void { + this.#cards = cards; + for (let listener of this.#listeners) listener(); + } +} + +export function useCardMeta( + store: CardMetaStore | undefined, + id: string, +): Question.CardMeta | undefined { + let cards = useSyncExternalStore( + store?.subscribe ?? noop, + store?.snapshot ?? empty, + store?.snapshot ?? empty, + ); + return cards.get(id); +} + +function noop() { + return () => {}; +} + +function empty() { + return EMPTY; +} diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 1ea763c5..73974683 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -1,16 +1,27 @@ import { describe, expect, it } from "bun:test"; -import { advanceDecisionView, countUnanswered, selectDecisionView, visibleDecisionView } from "."; +import { + advanceDecisionView, + countUnanswered, + documentHasPlanningContent, + selectDecisionView, + visibleDecisionView, +} from "."; import type { DecisionViewState } from "."; +import type { Question } from "@chopin/protocol"; import type { QuestionnaireEntry } from "./questionnaires"; -function entry(answers: Array): QuestionnaireEntry { +function entry( + answers: Array, + status?: Question.CardMeta["status"], +): QuestionnaireEntry { return { id: String(answers.length), value: { id: String(answers.length), + ...(status ? { status } : {}), questions: answers.map((answer, index) => ({ id: `q${index}`, header: `Question ${index + 1}`, @@ -23,12 +34,69 @@ function entry(answers: Array): QuestionnaireEntry { }; } +function meta(status: Question.CardMeta["status"]): Question.CardMeta { + return { + status, + origin: "planner", + involved: [], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, + }; +} + describe("decision attention", () => { it("counts unresolved questions rather than questionnaire cards", () => { expect(countUnanswered([entry([undefined, undefined]), entry(["Done"])])) .toBe(2); }); + it("excludes metadata-settled cards while preserving legacy multi-question counts", () => { + let waiting = { ...entry([undefined, undefined]), id: "waiting" }; + let decided = { ...entry([undefined]), id: "decided" }; + let discarded = { ...entry([undefined]), id: "discarded" }; + let metadata = new Map([ + ["decided", meta("decided")], + ["discarded", meta("discarded")], + ]); + + expect(countUnanswered([waiting, decided, discarded], metadata)).toBe(2); + }); + + it("counts every question when metadata reopens a card before its old projection updates", () => { + let reopened = { + ...entry(["Earlier choice", "Earlier answer"]), + id: "reopened", + }; + expect(countUnanswered( + [reopened], + new Map([[ + "reopened", + meta("reopened"), + ]]), + )).toBe(2); + }); + + it("preserves unanswered question counts for partially answered open cards", () => { + let open = { ...entry(["Earlier choice", undefined]), id: "open" }; + expect(countUnanswered( + [open], + new Map([[ + "open", + meta("open"), + ]]), + )).toBe(1); + }); + + it("uses persisted card status when metadata is unavailable", () => { + expect(countUnanswered([ + entry([undefined, undefined], "open"), + entry([undefined], "discarded"), + ])).toBe(2); + }); + it("forces only a questionnaire-only opening document into Decisions", () => { expect(visibleDecisionView({ phase: "initial", preferred: "plan" }, false, 2)).toBe( "decisions", @@ -39,6 +107,39 @@ describe("decision attention", () => { ); }); + it("keeps a chat-linked inline card in Document while Planner questions still open Decisions", () => { + let plannerQuestion = entry([undefined]); + let conversationQuestion = entry([undefined]); + conversationQuestion.value = { ...conversationQuestion.value, thread: "thread-1" }; + let initial: DecisionViewState = { phase: "initial", preferred: "plan" }; + let plannerHasContent = documentHasPlanningContent(false, [plannerQuestion]); + let conversationHasContent = documentHasPlanningContent(false, [conversationQuestion]); + + expect(visibleDecisionView( + initial, + plannerHasContent, + countUnanswered([plannerQuestion]), + )).toBe("decisions"); + expect(visibleDecisionView( + initial, + conversationHasContent, + countUnanswered([conversationQuestion]), + )).toBe("plan"); + + let state = advanceDecisionView( + initial, + plannerHasContent, + countUnanswered([plannerQuestion]), + ); + expect(state.phase).toBe("forced"); + state = advanceDecisionView( + state, + conversationHasContent, + countUnanswered([plannerQuestion, conversationQuestion]), + ); + expect(state).toEqual({ phase: "complete", preferred: "plan" }); + }); + it.each(["plan", "decisions"] as const)( "keeps a saved %s preference out of an opening transition until prose exists", preference => { diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index ac7d629d..3cfb7b57 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -1,4 +1,7 @@ import type { QuestionnaireEntry } from "./questionnaires"; +import { cardStatus } from "@chopin/dialect"; + +import type { Question } from "@chopin/protocol"; export type DecisionView = "plan" | "decisions"; export type OpeningPhase = "initial" | "forced" | "complete"; @@ -8,14 +11,30 @@ export type DecisionViewState = { preferred: DecisionView; }; -export function countUnanswered(entries: QuestionnaireEntry[]): number { +export function countUnanswered( + entries: QuestionnaireEntry[], + cardMeta?: ReadonlyMap, +): number { return entries.reduce( - (total, entry) => - total + entry.value.questions.filter(question => question.answer === undefined).length, + (total, entry) => { + let status = cardMeta?.get(entry.id)?.status ?? cardStatus(entry.value); + if (status === "decided" || status === "discarded") return total; + if (status === "reopened") return total + entry.value.questions.length; + return total + + entry.value.questions.filter(question => question.answer === undefined).length; + }, 0, ); } +/** A conversation-linked card is authored document content even before prose exists. */ +export function documentHasPlanningContent( + hasPlanProse: boolean, + entries: QuestionnaireEntry[], +): boolean { + return hasPlanProse || entries.some(entry => !!entry.value.thread); +} + /** A forced opening yields to Plan only when prose first arrives. */ export function advanceDecisionView( state: DecisionViewState, diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 1e2497de..7ae5f2c6 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,5 +1,6 @@ export { SidecarCard } from "./card"; export type { SidecarCardProps } from "./card"; +export { CardMetaStore, useCardMeta } from "./card-meta"; export { collaborationPlugin } from "./collaboration"; export type { CollaborationOptions } from "./collaboration"; export { ContentSwapLayer } from "./content-swap"; @@ -10,6 +11,7 @@ export type { Cursor } from "./cursor"; export { advanceDecisionView, countUnanswered, + documentHasPlanningContent, selectDecisionView, visibleDecisionView, } from "./decision-state"; diff --git a/packages/editor/src/provider.test.ts b/packages/editor/src/provider.test.ts index ef55022d..bca47cf6 100644 --- a/packages/editor/src/provider.test.ts +++ b/packages/editor/src/provider.test.ts @@ -37,6 +37,7 @@ function reply(): Plan.Open.Reply { revision: 1, anchors: [], threads: [], + prose: [], limits: { source: 1_000, update: 1_000, depth: 10 }, }; } @@ -48,9 +49,15 @@ function reply(): Plan.Open.Reply { * a socket has not finished its handshake by the time something mounts against * it — and refuses the request the way the real one does. */ -function wire(open = true): Transport & { sent: string[]; asked: object[]; up: boolean } { +function wire(open = true): Transport & { + sent: string[]; + asked: object[]; + up: boolean; + emit: (kind: string, frame: unknown) => void; +} { let sent: string[] = []; let asked: object[] = []; + let handlers = new Map void>>(); return { sent, @@ -59,8 +66,14 @@ function wire(open = true): Transport & { sent: string[]; asked: object[]; up: b get connected(): boolean { return this.up; }, - on(): Unsubscribe { - return () => {}; + on(kind: string, handler: (frame: T) => void): Unsubscribe { + let set = handlers.get(kind); + if (!set) handlers.set(kind, set = new Set()); + set.add(handler as (frame: never) => void); + return () => set!.delete(handler as (frame: never) => void); + }, + emit(kind: string, frame: unknown) { + for (let handler of handlers.get(kind) ?? []) handler(frame as never); }, send(kind: string) { sent.push(kind); @@ -119,7 +132,7 @@ describe("opening the plan", () => { }); it("hands over the anchors it was given", async () => { - let seen: Array<{ widgets: unknown[]; threads: unknown[] }> = []; + let seen: Array<{ widgets: unknown[]; threads: unknown[]; prose: unknown[] }> = []; let provider = new PlanProvider({ wire: wire(), doc: new Y.Doc(), @@ -129,6 +142,68 @@ describe("opening the plan", () => { await provider.connect(); expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ widgets: [], threads: [], prose: [] }); + }); + + it("passes current-epoch prose snapshots through and drops stale ones", async () => { + let transport = wire(); + let seen: Array<{ widgets: unknown[]; threads: unknown[]; prose: unknown[] }> = []; + let provider = new PlanProvider({ + wire: transport, + doc: new Y.Doc(), + onAnchors: snapshot => seen.push(snapshot), + }); + await provider.connect(); + let prose: Plan.ProseAnchors = { + widget: "card-a", + anchors: [], + orphaned: true, + }; + transport.emit( + "plan:anchors", + { + kind: "plan:anchors", + ts: 0, + epoch: "old-epoch", + widgets: [], + threads: [], + prose: [prose], + } satisfies Plan.Anchors, + ); + expect(seen).toHaveLength(1); + transport.emit( + "plan:anchors", + { + kind: "plan:anchors", + ts: 0, + epoch: reply().epoch, + widgets: [], + threads: [], + prose: [prose], + } satisfies Plan.Anchors, + ); + expect(seen.at(-1)?.prose).toEqual([prose]); + }); + + it("treats a pre-prose anchors frame as an empty prose snapshot", async () => { + let transport = wire(); + let seen: Array<{ prose: Plan.ProseAnchors[] }> = []; + let provider = new PlanProvider({ + wire: transport, + doc: new Y.Doc(), + onAnchors: snapshot => seen.push(snapshot), + }); + await provider.connect(); + + transport.emit("plan:anchors", { + kind: "plan:anchors", + ts: 0, + epoch: reply().epoch, + widgets: [], + threads: [], + } as unknown as Plan.Anchors); + + expect(seen.at(-1)?.prose).toEqual([]); }); /** diff --git a/packages/editor/src/provider.ts b/packages/editor/src/provider.ts index 0b81b10a..b0d12baa 100644 --- a/packages/editor/src/provider.ts +++ b/packages/editor/src/provider.ts @@ -48,12 +48,18 @@ export type PlanProviderOptions = { /** * Authoritative snapshot of which prose each decision and comment names. * - * The whole snapshot rather than one half: the two arrive together because + * The whole snapshot: these relationships arrive together because * they describe the same document at the same moment, and splitting them - * into two callbacks would let a consumer act on one while holding a stale - * copy of the other. + * into separate callbacks would let a consumer act on one while holding a + * stale copy of another. */ - onAnchors?: (snapshot: { widgets: Plan.WidgetAnchors[]; threads: Plan.ThreadAnchors[] }) => void; + onAnchors?: ( + snapshot: { + widgets: Plan.WidgetAnchors[]; + threads: Plan.ThreadAnchors[]; + prose: Plan.ProseAnchors[]; + }, + ) => void; /** * What the agent just did, to be marked in the prose. * @@ -198,7 +204,11 @@ export class PlanProvider implements Provider { }), this.#wire.on("plan:anchors", event => { if (event.epoch === this.#epoch) { - this.#anchors({ widgets: event.widgets, threads: event.threads }); + this.#anchors({ + widgets: event.widgets, + threads: event.threads, + prose: event.prose ?? [], + }); } }), ); @@ -284,7 +294,7 @@ export class PlanProvider implements Provider { // out where a decision pointed skipped the emit — and the editor stayed // locked for the rest of the session, with the rejection swallowed by // the `void connect()` that started it. - this.#anchors({ widgets: reply.anchors, threads: reply.threads }); + this.#anchors({ widgets: reply.anchors, threads: reply.threads, prose: reply.prose ?? [] }); } /** @@ -294,7 +304,11 @@ export class PlanProvider implements Provider { * taking the document with it. A reader losing an outline is a nuisance; a * room that cannot be edited is not. */ - #anchors(snapshot: { widgets: Plan.WidgetAnchors[]; threads: Plan.ThreadAnchors[] }): void { + #anchors(snapshot: { + widgets: Plan.WidgetAnchors[]; + threads: Plan.ThreadAnchors[]; + prose: Plan.ProseAnchors[]; + }): void { try { this.#options.onAnchors?.(snapshot); } catch (err) { diff --git a/packages/editor/src/questionnaires.test.ts b/packages/editor/src/questionnaires.test.ts index 902e08b6..482fbbc7 100644 --- a/packages/editor/src/questionnaires.test.ts +++ b/packages/editor/src/questionnaires.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; -import { $createParagraphNode, $getRoot } from "lexical"; +import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; +import { $createParagraphNode, $getNodeByKey, $getRoot } from "lexical"; +import * as Y from "yjs"; import { importPlan, registry } from "@chopin/dialect"; -import { collectPlanState } from "./questionnaires"; +import { collectPlanState, QuestionnaireStore } from "./questionnaires"; +import type { Binding, Provider } from "@lexical/yjs"; import type { LexicalEditor } from "lexical"; +import type { Plan } from "@chopin/protocol"; import type { PlanQuestionnaireState } from "./questionnaires"; const REGISTRY = registry(); @@ -71,3 +75,172 @@ describe("plan questionnaire state", () => { } }); }); + +const PROVIDER = { + awareness: { + getLocalState: () => null, + getStates: () => new Map(), + off() {}, + on() {}, + setLocalState() {}, + setLocalStateField() {}, + }, + connect() {}, + disconnect() {}, + off() {}, + on() {}, +} as unknown as Provider; + +function proseRoom( + source = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n", +): { editor: LexicalEditor; binding: Binding; keys: string[] } { + let editor = createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError: error => { + throw error; + }, + }); + let doc = new Y.Doc(); + let binding = createYjsBinding({ editor, id: "plan", doc, docMap: new Map([["plan", doc]]) }); + editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { + if (tags.has("skip-collab")) return; + syncLexicalUpdateToYjs( + binding, + PROVIDER, + prevEditorState, + editorState, + dirtyElements, + dirtyLeaves, + normalizedNodes, + tags, + ); + }, + ); + importPlan(editor, source, { registry: REGISTRY }); + let keys: string[] = []; + editor.getEditorState().read(() => { + keys = $getRoot().getChildren().map(node => node.getKey()); + }); + return { editor, binding, keys }; +} + +function proseAnchor(binding: Binding, key: string): Plan.Anchor { + let type = binding.collabNodeMap.get(key)?.getSharedType(); + if (!type) throw new Error(`no collaborative block ${key}`); + let binary = ""; + for (let byte of Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(type, 0, -1))) { + binary += String.fromCharCode(byte); + } + return { epoch: "e1", position: btoa(binary), digest: "sha256:paragraph" }; +} + +function prose(widget: string, anchor: Plan.Anchor, orphaned = false): Plan.ProseAnchors { + return { widget, anchors: [anchor], orphaned }; +} + +describe("decided prose targets", () => { + it("resolves a pre-binding snapshot in document order and ignores orphaned prose", () => { + let { binding, keys } = proseRoom(); + let store = new QuestionnaireStore(); + let updates = 0; + store.subscribe(() => updates++); + store.prose([ + prose("third", proseAnchor(binding, keys[2]!)), + prose("removed", proseAnchor(binding, keys[1]!), true), + prose("first", proseAnchor(binding, keys[0]!)), + ]); + store.bind(binding); + + expect(store.proseTargets()).toEqual([ + { widget: "first", key: keys[0] }, + { widget: "third", key: keys[2] }, + ]); + expect(store.proseKey("removed")).toBeUndefined(); + expect(updates).toBe(1); + }); + + it("clears old targets and snapshots across teardown and rebind", () => { + let first = proseRoom(); + let second = proseRoom(); + let store = new QuestionnaireStore(); + store.attach(first.editor); + store.bind(first.binding); + store.prose([prose("old", proseAnchor(first.binding, first.keys[0]!))]); + expect(store.proseKey("old")).toBe(first.keys[0]); + + store.attach(undefined); + store.bind(undefined); + store.prose([prose("late-old", proseAnchor(first.binding, first.keys[1]!))]); + store.attach(second.editor); + store.bind(second.binding); + expect(store.proseTargets()).toEqual([]); + + store.prose([prose("new", proseAnchor(second.binding, second.keys[2]!))]); + expect(store.proseTargets()).toEqual([{ widget: "new", key: second.keys[2] }]); + }); + + it("discards a pending snapshot when an epoch resets before binding", () => { + let { binding, keys } = proseRoom(); + let store = new QuestionnaireStore(); + store.prose([prose("old", proseAnchor(binding, keys[0]!))]); + + store.bind(undefined); + store.bind(binding); + + expect(store.proseTargets()).toEqual([]); + }); + + it("reorders moved prose and drops a deleted paragraph on refresh", () => { + let { binding, editor, keys } = proseRoom(); + let store = new QuestionnaireStore(); + store.bind(binding); + store.prose([ + prose("first", proseAnchor(binding, keys[0]!)), + prose("third", proseAnchor(binding, keys[2]!)), + ]); + + editor.update(() => { + $getRoot().getChildren()[0]!.insertBefore($getRoot().getChildren()[2]!); + }, { discrete: true }); + store.prose([ + prose("first", proseAnchor(binding, keys[0]!)), + prose("third", proseAnchor(binding, keys[2]!)), + ]); + store.refreshProse(); + expect(store.proseTargets()).toEqual([ + { widget: "third", key: keys[2] }, + { widget: "first", key: keys[0] }, + ]); + + editor.update(() => { + $getNodeByKey(keys[0]!)?.remove(); + }, { discrete: true }); + store.refreshProse(); + expect(store.proseTargets()).toEqual([{ widget: "third", key: keys[2] }]); + }); + + it("re-resolves anchors received before the collaborative block reaches Lexical", async () => { + let source = proseRoom(); + let target = proseRoom(""); + let store = new QuestionnaireStore(); + store.bind(target.binding); + let pending: Parameters[2] = []; + target.binding.root.getSharedType().observeDeep(events => { + pending = events.filter(event => event.delta !== undefined) as typeof pending; + }); + + Y.applyUpdate(target.binding.doc, Y.encodeStateAsUpdate(source.binding.doc)); + store.prose([prose("late", proseAnchor(source.binding, source.keys[1]!))]); + expect(store.proseKey("late")).toBeUndefined(); + + syncYjsChangesToLexical(target.binding, PROVIDER, pending, false, () => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + store.refreshProse(); + let text: string | undefined; + target.editor.getEditorState().read(() => { + text = $getNodeByKey(store.proseKey("late") ?? "")?.getTextContent(); + }); + expect(text).toBe("Second paragraph."); + }); +}); diff --git a/packages/editor/src/questionnaires.ts b/packages/editor/src/questionnaires.ts index 481abf8e..289d4293 100644 --- a/packages/editor/src/questionnaires.ts +++ b/packages/editor/src/questionnaires.ts @@ -16,7 +16,7 @@ import { useEffect } from "react"; import { $isDecisionNode, $isQuestionnaireNode, QuestionnaireNode } from "@chopin/dialect"; -import { counts, relate } from "./anchors"; +import { counts, relate, resolve } from "./anchors"; import { holds, paint, pin, unpin } from "./marks"; import { $blockPoints } from "./passage"; import { scrollToKey } from "./scroll"; @@ -65,6 +65,9 @@ export class QuestionnaireStore { #binding: Binding | undefined; #editor: LexicalEditor | undefined; #related: Related[] = []; + #proseSnapshot: Plan.ProseAnchors[] | undefined; + #prose: Array<{ widget: string; key: string }> = []; + #hasBound = false; /** * Which decision the reader last asked to be taken to, and how far along * it. Never cleared: whether it is still live is the pin's answer. @@ -98,13 +101,79 @@ export class QuestionnaireStore { attach(editor: LexicalEditor | undefined): void { // Everything, not just the preview: a pin outlives the pointer but it // cannot outlive the document it names. - if (!editor && this.#editor) this.release(); + if (this.#editor && this.#editor !== editor) { + this.release(); + this.bind(undefined); + } this.#editor = editor; } /** The Yjs binding, so a relative position can be turned into a node key. */ bind(binding: Binding | undefined): void { + if (binding && binding === this.#binding) return; + if (this.#binding || !binding) { + this.#proseSnapshot = undefined; + this.#publishProse([]); + } this.#binding = binding; + this.#hasBound = true; + this.refreshProse(); + } + + /** The paragraph each decided card became, resolved against this binding. */ + prose(snapshot: Plan.ProseAnchors[]): void { + // An old provider can finish after teardown. Only the first binding may + // accept a snapshot before it exists; later bindings need a fresh one. + if (!this.#binding && this.#hasBound) return; + this.#proseSnapshot = snapshot; + this.refreshProse(); + } + + proseKey(widget: string): string | undefined { + return this.#prose.find(item => item.widget === widget)?.key; + } + + proseTargets(): Array<{ widget: string; key: string }> { + return this.#prose; + } + + /** Re-resolve retained anchors after Lexical catches up to a Yjs update. */ + refreshProse(): void { + let binding = this.#binding; + let snapshot = this.#proseSnapshot; + if (!binding || !snapshot) return; + try { + let next: Array<{ widget: string; key: string; order: number }> = []; + binding.editor.getEditorState().read(() => { + let order = new Map( + $getRoot().getChildren().flatMap((node, index) => + $isParagraphNode(node) ? [[node.getKey(), index] as const] : [] + ), + ); + for (let item of snapshot) { + if (item.orphaned) continue; + let key = item.anchors.map(anchor => resolve(binding, anchor)) + .find(value => value !== undefined && order.has(value)); + if (key) next.push({ widget: item.widget, key, order: order.get(key)! }); + } + }); + next.sort((a, b) => a.order - b.order); + this.#publishProse(next.map(({ widget, key }) => ({ widget, key }))); + } catch (err) { + console.error("[plan] could not resolve decided prose:", err); + this.#publishProse([]); + } + } + + #publishProse(next: Array<{ widget: string; key: string }>): void { + if ( + next.length === this.#prose.length + && next.every((item, index) => + item.widget === this.#prose[index]?.widget && item.key === this.#prose[index]?.key + ) + ) return; + this.#prose = next; + for (let listener of this.#listeners) listener(); } /** @@ -225,7 +294,10 @@ export function QuestionnaireObserver({ store }: { store: QuestionnaireStore }) useEffect(() => { store.attach(editor); - let read = () => editor.getEditorState().read(() => store.set(collectPlanState())); + let read = () => { + editor.getEditorState().read(() => store.set(collectPlanState())); + store.refreshProse(); + }; read(); let off = editor.registerUpdateListener(read); return () => { diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 76f0d3ba..00599a65 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -5,6 +5,7 @@ export { ChevronIcon, CloseIcon, CodeIcon, + DecisionIcon, InfoIcon, LightbulbIcon, LinkPlusIcon, diff --git a/packages/icons/src/line.tsx b/packages/icons/src/line.tsx index 2790d566..8cb78bd6 100644 --- a/packages/icons/src/line.tsx +++ b/packages/icons/src/line.tsx @@ -44,6 +44,17 @@ export function CheckIcon(props: IconProps) { ); } +export function DecisionIcon(props: IconProps) { + return ( + + + + + + + ); +} + export function InfoIcon(props: IconProps) { return ( diff --git a/packages/question/src/react/index.ts b/packages/question/src/react/index.ts index 57b7351d..1e573ae1 100644 --- a/packages/question/src/react/index.ts +++ b/packages/question/src/react/index.ts @@ -5,6 +5,8 @@ * helpers without pulling React into a headless process. */ +export { projectSuggestion } from "./project-suggestion"; +export type { SuggestionProjection, VisibleSuggestion } from "./project-suggestion"; export { QuestionView } from "./question-view"; export type { Collaborator, QuestionStepRenderProps, QuestionViewProps } from "./question-view"; export { forget, useQuestionnaire } from "./use-questionnaire"; diff --git a/packages/question/src/react/project-suggestion.test.ts b/packages/question/src/react/project-suggestion.test.ts new file mode 100644 index 00000000..31df3d87 --- /dev/null +++ b/packages/question/src/react/project-suggestion.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { projectSuggestion } from "./project-suggestion"; + +const AUTH = { + questions: [{ + id: "q", + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: [ + { id: "a", label: "Auth0", description: "" }, + { id: "b", label: "GitHub Apps", description: "" }, + ], + }], +}; + +test("a visible suggestion preserves its exact revision snapshot", () => { + let draft = { + mode: "choices" as const, + choice: null, + options: { a: false, b: false }, + custom: "", + }; + let suggestion = { optionId: "b", revision: 7 }; + let projection = projectSuggestion(AUTH.questions[0], draft, suggestion); + + expect(projection.draft?.choice).toBe("b"); + expect(projection.suggestion).toBe(suggestion); +}); + +test("human choices and local edits take precedence over later suggestions", () => { + let selected = { + mode: "choices" as const, + choice: "a", + options: { a: false, b: false }, + custom: "", + }; + let newer = { optionId: "a", revision: 8 }; + let unsent = { ...selected, choice: null }; + let humanChoice = projectSuggestion(AUTH.questions[0], selected, newer); + let queuedEdit = projectSuggestion(AUTH.questions[0], unsent, newer, true); + + expect(humanChoice.draft).toBe(selected); + expect(humanChoice.suggestion).toBeUndefined(); + expect(queuedEdit.draft).toBe(unsent); + expect(queuedEdit.suggestion).toBeUndefined(); +}); diff --git a/packages/question/src/react/project-suggestion.ts b/packages/question/src/react/project-suggestion.ts new file mode 100644 index 00000000..2932f7c5 --- /dev/null +++ b/packages/question/src/react/project-suggestion.ts @@ -0,0 +1,36 @@ +import type { Draft } from "../draft"; +import type { Item } from "../schema"; + +export type VisibleSuggestion = { optionId: string; revision: number }; + +export type SuggestionProjection = { draft?: Draft; suggestion?: VisibleSuggestion }; + +export function projectSuggestion( + question: Item, + draft: Draft | undefined, + suggestion: VisibleSuggestion | undefined, + suppressed = false, +): SuggestionProjection { + if ( + !suggestion || suppressed || question.multiple + || !question.options.some(option => option.id === suggestion.optionId) + ) return { draft }; + + let untouched = !draft || ( + draft.mode === "choices" && draft.choice === null && draft.custom.length === 0 + && !Object.values(draft.options).some(Boolean) + ); + if (!untouched) return { draft }; + + let empty: Draft = draft ?? { + mode: "choices", + choice: null, + options: Object.fromEntries(question.options.map(option => [option.id, false])), + custom: "", + }; + // Keep the original snapshot so Save submits the revision that was displayed. + return { + draft: { ...empty, mode: "choices", choice: suggestion.optionId }, + suggestion, + }; +} diff --git a/packages/question/src/react/questionnaire-controller.ts b/packages/question/src/react/questionnaire-controller.ts new file mode 100644 index 00000000..5d79e28d --- /dev/null +++ b/packages/question/src/react/questionnaire-controller.ts @@ -0,0 +1,513 @@ +import { derive } from "../answer"; +import { crdt } from "../draft"; +import { identified } from "../schema"; +import type { Definition, Drafts } from "../index"; +import type { Collaborator } from "./question-view"; +import type { Snapshot, Transport, Unsubscribe } from "./questionnaire-types"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 class; current cancel retained. +type Model = crdt.Model>; + +const EMPTY_DRAFTS: Drafts = Object.freeze({}); + +function normalize(person: { + client: string; + handle?: string; + question?: string; +}): Collaborator { + return { + client: person.client, + handle: person.handle ?? "unknown", + ...(person.question ? { question: person.question } : {}), + }; +} + +export class QuestionnaireController { + readonly id: string; + readonly bridge: Transport | undefined; + + #snapshot: Snapshot; + #listeners = new Set<() => void>(); + #teardown: Unsubscribe[] = []; + #model: Model | undefined; + #definition: Definition | undefined; + #revision = 0; + #outbox: number[][] = []; + #pending = Promise.resolve(); + #opening: Promise = Promise.resolve(); + #generationWaiters = new Set<() => void>(); + #generation = 0; + #refs = 0; + #connected: boolean; + #active = false; + #terminal = false; + + constructor( + bridge: Transport | undefined, + id: string, + definition: Definition | undefined, + connected: boolean, + ) { + this.bridge = bridge; + this.id = id; + this.#connected = connected; + this.#snapshot = { + definition, + drafts: EMPTY_DRAFTS, + collaborators: [], + syncing: !!bridge, + submitting: false, + error: undefined, + focus: undefined, + closed: false, + }; + } + + getSnapshot = (): Snapshot => this.#snapshot; + + subscribe = (listener: () => void): Unsubscribe => { + this.#listeners.add(listener); + this.#refs++; + if (this.#refs === 1) this.#start(); + + return () => { + this.#listeners.delete(listener); + this.#refs--; + // React Strict Mode subscribes, cleans up, then subscribes again. A + // microtask grace period turns that into one transport lifecycle rather + // than two opens with a spurious presence clear between them. + if (this.#refs === 0) { + queueMicrotask(() => this.#stopIfUnobserved()); + } + }; + }; + + configure(definition: Definition | undefined, connected: boolean): void { + if (definition && !this.#snapshot.definition) this.#set({ definition }); + if (connected === this.#connected) return; + this.#connected = connected; + if (!connected) { + this.#stop(false); + this.#set({ syncing: !!this.bridge, collaborators: [] }); + } else if (this.#refs > 0) { + this.#start(); + } + } + + change = (question: string, patch: Record): void => { + let doc = this.#model; + if (!doc || this.#snapshot.closed || this.#terminal || this.#snapshot.submitting) return; + this.#set({ error: undefined, focus: undefined }); + + doc.api.transaction(() => { + if (patch.mode !== undefined) doc.api.val([question, "mode"]).set(patch.mode); + if (patch.choice !== undefined) { + doc.api.val([question, "mode"]).set("choices"); + doc.api.val([question, "choice"]).set(patch.choice); + } + if (patch.options !== undefined) { + doc.api.val([question, "mode"]).set("choices"); + for (let [option, value] of Object.entries(patch.options as Record)) { + doc.api.val([question, "options", option]).set(value); + } + } + if (patch.custom !== undefined) { + let value = doc.api.str([question, "custom"]); + value.del(0, value.length()); + value.ins(0, patch.custom as string); + } + }); + }; + + submit = (visibleSuggestion?: { optionId: string; revision: number }): void => { + let definition = this.#definition; + let doc = this.#model; + if ( + !this.bridge || !definition || !doc || this.#snapshot.closed || this.#terminal + || this.#snapshot.submitting + ) return; + + let drafts = doc.view() as Drafts; + let outcome = derive(definition, drafts); + let suggestion: { optionId: string; revision: number } | undefined; + if (!outcome.ok && visibleSuggestion) { + let question = definition.questions[0]; + let draft = question && drafts[question.id]; + if ( + definition.questions.length === 1 && question && !question.multiple && draft + && draft.mode === "choices" && draft.choice === null && draft.custom.length === 0 + && !Object.values(draft.options).some(Boolean) + && question.options.some(option => option.id === visibleSuggestion.optionId) + && Number.isSafeInteger(visibleSuggestion.revision) + ) { + suggestion = { + optionId: visibleSuggestion.optionId, + revision: visibleSuggestion.revision, + }; + outcome = derive(definition, { + ...drafts, + [question.id]: { ...draft, choice: suggestion.optionId }, + }); + } + } + if (!outcome.ok) { + this.#set({ + focus: outcome.question, + error: outcome.message, + }); + return; + } + + this.#terminal = true; + this.#set({ submitting: true, error: undefined }); + let submit = async () => { + // The CRDT batches its change callback into a microtask. Let the final + // input event enter the outbox before choosing the revision to submit. + await Promise.resolve(); + while (true) { + if (this.#snapshot.closed) return; + if (!this.#active || !this.#connected) { + throw new Error("questionnaire is disconnected"); + } + let generation = this.#generation; + let wake!: () => void; + let changed = new Promise(resolve => wake = resolve); + this.#generationWaiters.add(wake); + try { + // A definition refresh abandons the old edit queue. Wait for the + // new open and its replay instead of waiting for an obsolete ack. + await Promise.race([this.#opening, changed]); + if (generation !== this.#generation) continue; + if (this.#snapshot.closed) return; + if (!this.#model) throw new Error("questionnaire is not synchronized"); + let pending = this.#pending; + await Promise.race([pending, changed]); + if (generation !== this.#generation || pending !== this.#pending) continue; + if (this.#outbox.length > 0) { + throw new Error("questionnaire edits are not synchronized"); + } + return this.bridge!.ask("question:submit", { + id: this.id, + revision: suggestion?.revision ?? this.#revision, + ...(suggestion ? { suggestedOptionId: suggestion.optionId } : {}), + }); + } finally { + this.#generationWaiters.delete(wake); + } + } + }; + void submit() + .then((raw: unknown) => { + if (this.#snapshot.closed) return; + let reply = raw as unknown as { ok?: boolean; reason?: string; message?: string }; + if (reply.ok) return; + if (reply.reason === "stale") { + this.#terminal = false; + this.#set({ error: "Answers changed while submitting. Review and try again." }); + this.#restart(); + } else if (reply.reason === "resolved") { + this.#close(); + } else { + this.#terminal = false; + this.#set({ error: reply.message ?? "Could not submit these answers." }); + } + }) + .catch(() => { + if (this.#snapshot.closed) return; + this.#terminal = false; + this.#set({ error: "Could not submit these answers." }); + }) + .finally(() => { + if (!this.#terminal) this.#set({ submitting: false }); + this.#stopIfUnobserved(); + }); + }; + + discard = (): void => { + if (!this.bridge || this.#snapshot.closed || this.#terminal || this.#snapshot.submitting) { + return; + } + this.#terminal = true; + this.#set({ submitting: true, error: undefined }); + + void this.bridge.ask("question:discard", { id: this.id }) + .then((raw: never) => { + let reply = raw as unknown as { ok?: boolean; reason?: string }; + if (reply.ok || reply.reason === "resolved") this.#close(); + else { + this.#terminal = false; + this.#set({ error: "Could not discard this decision." }); + } + }) + .catch(() => { + this.#terminal = false; + this.#set({ error: "Could not discard this decision." }); + }) + .finally(() => { + if (!this.#terminal) this.#set({ submitting: false }); + this.#stopIfUnobserved(); + }); + }; + + cancel = (): void => { + if (!this.bridge || this.#snapshot.closed || this.#terminal || this.#snapshot.submitting) { + return; + } + this.#terminal = true; + this.#set({ submitting: true, error: undefined }); + + void this.bridge.ask("question:cancel", { id: this.id }) + .then((raw: never) => { + let reply = raw as unknown as { ok?: boolean; reason?: string }; + if (reply.ok || reply.reason === "resolved") this.#close(); + else { + this.#terminal = false; + this.#set({ error: "Could not cancel this question." }); + } + }) + .catch(() => { + this.#terminal = false; + this.#set({ error: "Could not cancel this question." }); + }) + .finally(() => { + if (!this.#terminal) this.#set({ submitting: false }); + this.#stopIfUnobserved(); + }); + }; + + reopen = async (): Promise<{ ok: true } | { ok: false; message: string }> => { + if (!this.bridge || !this.#connected) return { ok: false, message: "Not connected." }; + try { + let reply = await this.bridge.ask("question:reopen", { id: this.id }) as unknown as { + ok: boolean; + reason?: string; + }; + if (reply.ok) return { ok: true }; + return { + ok: false, + message: reply.reason === "not-decided" + ? "Only a decided card can be reopened." + : "Could not reopen it. Try again.", + }; + } catch { + return { ok: false, message: "Could not reopen it. Try again." }; + } + }; + + addOption = async (label: string): Promise<{ ok: true } | { ok: false; message: string }> => { + if (this.#snapshot.closed || this.#terminal) { + return { ok: false, message: "This decision is no longer open." }; + } + if (!this.bridge || !this.#connected) { + return { ok: false, message: "Reconnect to add an option." }; + } + try { + let reply = await this.bridge.ask("question:add-option", { + id: this.id, + label, + }) as unknown as { + ok: boolean; + message?: string; + }; + return reply.ok ? { ok: true } : { ok: false, message: reply.message ?? "Could not add it." }; + } catch { + return { ok: false, message: "Could not add it." }; + } + }; + + forget(): void { + this.#close(); + } + + #set(patch: Partial): void { + this.#snapshot = { ...this.#snapshot, ...patch }; + for (let listener of this.#listeners) listener(); + } + + #start(): void { + if (this.#active || !this.bridge || !this.#connected || this.#snapshot.closed) return; + this.#active = true; + this.#opening = this.#init(++this.#generation); + } + + #restart(): void { + this.#stop(false); + if (this.#refs > 0 || this.#snapshot.submitting) this.#start(); + } + + #stopIfUnobserved(): void { + if (this.#refs === 0 && !this.#snapshot.submitting) this.#stop(true); + } + + #stop(presence: boolean): void { + if (!this.#active) return; + this.#active = false; + this.#generation++; + for (let wake of this.#generationWaiters) wake(); + this.#generationWaiters.clear(); + for (let off of this.#teardown.splice(0)) off(); + this.#model = undefined; + this.#definition = undefined; + // A previous generation's edit may never answer. New opens replay every + // unacknowledged patch through their own queue. + this.#pending = Promise.resolve(); + if (presence && this.bridge && this.#connected) { + this.bridge.send("question:presence", { id: this.id }); + } + } + + #close(): void { + this.#terminal = true; + this.#outbox = []; + this.#set({ closed: true, syncing: false, submitting: false, collaborators: [] }); + this.#stop(false); + } + + async #init(generation: number): Promise { + let channel = this.bridge!; + this.#set({ syncing: true, error: undefined }); + + let buffered: number[][] = []; + let apply: ((patch: number[]) => void) | undefined; + + this.#teardown.push( + channel.on("question:changed", (raw: never) => { + let event = raw as unknown as { id: string; definition: Definition }; + if (event.id !== this.id) return; + this.#set({ definition: event.definition }); + queueMicrotask(() => { + if (this.#valid(generation)) this.#restart(); + }); + }), + channel.on("question:edit", (raw: never) => { + let event = raw as unknown as { + id: string; + open?: boolean; + accepted?: boolean; + applied?: boolean; + revision: number; + patch: number[]; + }; + if (event.id !== this.id || !event.open || !event.accepted) return; + this.#revision = Math.max(this.#revision, event.revision); + if (!event.applied) return; + if (apply) apply(event.patch); + else buffered.push(event.patch); + }), + channel.on("question:presence", (raw: never) => { + let event = raw as unknown as { + id: string; + client: string; + handle?: string; + question?: string; + }; + if (event.id !== this.id) return; + let person = normalize(event); + let next = this.#snapshot.collaborators.filter(item => item.client !== person.client); + this.#set({ collaborators: event.question ? [...next, person] : next }); + }), + channel.on("question:resolved", (raw: never) => { + let event = raw as unknown as { id: string }; + if (event.id === this.id) this.#close(); + }), + ); + + let reply: { + open: boolean; + definition?: Definition; + model?: number[]; + revision?: number; + presence?: Array<{ + client: string; + handle?: string; + question?: string; + }>; + }; + + try { + reply = await channel.ask("question:open", { id: this.id }) as never; + } catch { + if (!this.#valid(generation)) return; + this.#set({ error: "Unable to sync shared answers." }); + return; + } + + if (!this.#valid(generation)) return; + if (!reply.open) return this.#close(); + let definition: Definition; + try { + definition = identified(reply.definition!); + } catch { + this.#set({ syncing: false, error: "Unable to sync shared answers." }); + return; + } + + let doc = crdt.Model + .fromBinary>(new Uint8Array(reply.model!)) + .fork(); + + this.#revision = Math.max(this.#revision, reply.revision ?? 0); + apply = patch => { + doc.applyPatch(crdt.Patch.fromBinary(new Uint8Array(patch))); + this.#set({ drafts: doc.view() as Drafts }); + }; + for (let patch of buffered) apply(patch); + for (let patch of this.#outbox) apply(patch); + + let send = (patch: number[]) => { + this.#pending = this.#pending.then(() => { + if (!this.#valid(generation)) return; + return channel.ask("question:edit", { id: this.id, patch }) + .then((raw: never) => { + if (!this.#valid(generation)) return; + let ack = raw as unknown as { + open?: boolean; + accepted?: boolean; + revision?: number; + message?: string; + }; + let index = this.#outbox.indexOf(patch); + if (index !== -1) this.#outbox.splice(index, 1); + if (!ack.open) return this.#close(); + if (!ack.accepted) { + this.#set({ + error: `${ack.message ?? "An edit was rejected"}. Syncing latest answers.`, + }); + this.#restart(); + return; + } + this.#revision = Math.max(this.#revision, ack.revision ?? 0); + }) + .catch(() => { + // Left in the outbox for the next successful open. + }); + }); + }; + + let offChanges = doc.api.onChanges.listen(() => { + this.#set({ drafts: doc.view() as Drafts }); + let patch = doc.api.flush(); + if (patch.ops.length === 0) return; + let binary = Array.from(patch.toBinary() as Uint8Array); + this.#outbox.push(binary); + send(binary); + }); + + this.#teardown.push(offChanges); + this.#model = doc; + this.#definition = definition; + for (let patch of this.#outbox) send(patch); + + this.#set({ + definition, + drafts: doc.view() as Drafts, + collaborators: (reply.presence ?? []).map(normalize), + syncing: false, + error: undefined, + }); + } + + #valid(generation: number): boolean { + return this.#active && this.#generation === generation; + } +} diff --git a/packages/question/src/react/questionnaire-types.ts b/packages/question/src/react/questionnaire-types.ts new file mode 100644 index 00000000..4c3db68a --- /dev/null +++ b/packages/question/src/react/questionnaire-types.ts @@ -0,0 +1,42 @@ +import type { Definition, Drafts } from "../index"; +import type { Collaborator } from "./question-view"; + +// Archive shared state, with the current cancel API retained. +export type Unsubscribe = () => void; + +export type Transport = { + ask(kind: K, payload: Record): Promise; + send(kind: string, payload: Record): void; + on(kind: string, handler: (event: never) => void): Unsubscribe; +}; + +export type QuestionnaireState = { + definition: Definition | undefined; + drafts: Drafts; + collaborators: Collaborator[]; + /** True until the shared draft has arrived. */ + syncing: boolean; + submitting: boolean; + /** Validation or synchronisation problem, shown to the user. */ + error: string | undefined; + change: (question: string, change: Record) => void; + addOption: (label: string) => Promise<{ ok: true } | { ok: false; message: string }>; + submit: (visibleSuggestion?: { optionId: string; revision: number }) => void; + /** Set an open decision aside. Terminal — the agent stops waiting. */ + discard: () => void; + /** Ask the server to reopen a decided card; metadata and a new draft follow separately. */ + reopen: () => Promise<{ ok: true } | { ok: false; message: string }>; + /** Decline to answer. Terminal — the agent stops waiting. */ + cancel: () => void; + /** Set when submission stopped on an unanswered question. */ + focus: string | undefined; +}; + +export type Snapshot = + & Omit< + QuestionnaireState, + "change" | "addOption" | "submit" | "discard" | "reopen" | "cancel" + > + & { + closed: boolean; + }; diff --git a/packages/question/src/react/use-questionnaire-cancel.test.ts b/packages/question/src/react/use-questionnaire-cancel.test.ts new file mode 100644 index 00000000..e88dfa94 --- /dev/null +++ b/packages/question/src/react/use-questionnaire-cancel.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { create } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; +import { DEFINITION } from "./use-questionnaire.test-fixtures"; +import type { Transport } from "./use-questionnaire"; + +function cancelBridge() { + let model = create(DEFINITION); + let requests: string[] = []; + let handlers = new Map void>>(); + let settle!: (reply: unknown) => void; + let cancellation = new Promise(resolve => settle = resolve); + let value = { + ask(kind: string) { + requests.push(kind); + if (kind === "question:open") { + return Promise.resolve({ + open: true, + definition: DEFINITION, + model: [...model.toBinary()], + revision: 0, + presence: [], + }); + } + if (kind === "question:cancel") return cancellation; + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on(kind: string, handler: (event: never) => void) { + let listeners = handlers.get(kind) ?? new Set(); + handlers.set(kind, listeners); + listeners.add(handler); + return () => listeners.delete(handler); + }, + } as unknown as Transport; + return { value, requests, handlers, settle }; +} + +test("the existing cancel API still sends question:cancel and closes its controller", async () => { + let bridge = cancelBridge(); + let controller = new QuestionnaireController(bridge.value, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.cancel(); + expect(controller.getSnapshot().submitting).toBe(true); + bridge.settle({ ok: true }); + await Bun.sleep(0); + expect(bridge.requests).toEqual(["question:open", "question:cancel"]); + expect(controller.getSnapshot().closed).toBe(true); + expect([...bridge.handlers.values()].every(listeners => listeners.size === 0)).toBe(true); + off(); +}); + +test("a refused cancel releases its listeners after the last view unmounts", async () => { + let bridge = cancelBridge(); + let controller = new QuestionnaireController(bridge.value, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.cancel(); + off(); + await Bun.sleep(0); + bridge.settle({ ok: false }); + await Bun.sleep(0); + expect(bridge.requests).toEqual(["question:open", "question:cancel"]); + expect(controller.getSnapshot().error).toBe("Could not cancel this question."); + expect(controller.getSnapshot().submitting).toBe(false); + expect([...bridge.handlers.values()].every(listeners => listeners.size === 0)).toBe(true); +}); diff --git a/packages/question/src/react/use-questionnaire-lifecycle.test.ts b/packages/question/src/react/use-questionnaire-lifecycle.test.ts new file mode 100644 index 00000000..ef00a391 --- /dev/null +++ b/packages/question/src/react/use-questionnaire-lifecycle.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test"; +import { create } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; +import type { Transport } from "./use-questionnaire"; +import { DEFINITION, transport } from "./use-questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks, wrappers only. +describe("QuestionnaireController lifecycle", () => { + it("discards an open decision", async () => { + let bridge = transport(); + let controller = new QuestionnaireController(bridge.value, "question-1", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + + controller.discard(); + await Bun.sleep(0); + + expect(bridge.discards()).toEqual(["question-1"]); + expect(controller.getSnapshot().closed).toBe(true); + off(); + }); + + it("reopen reports a server refusal without changing the closed controller", async () => { + let asked: string[] = []; + let wire = { + ask: async (kind: string) => { + asked.push(kind); + return { ok: false, reason: "not-decided" }; + }, + send() {}, + on: () => () => {}, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + controller.forget(); + expect(await controller.reopen()).toEqual({ + ok: false, + message: "Only a decided card can be reopened.", + }); + expect(asked).toEqual(["question:reopen"]); + expect(controller.getSnapshot().closed).toBe(true); + }); + + it("reopen reports connection and transport failures", async () => { + let offline = new QuestionnaireController(undefined, "w", DEFINITION, false); + expect(await offline.reopen()).toEqual({ ok: false, message: "Not connected." }); + + let wire = { + ask: async () => { + throw new Error("connection lost"); + }, + send() {}, + on: () => () => {}, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + expect(await controller.reopen()).toEqual({ + ok: false, + message: "Could not reopen it. Try again.", + }); + }); + + it("does not revive a retired draft from a late open reply", async () => { + let reply!: (value: unknown) => void; + let wire = { + ask: () => new Promise(resolve => reply = resolve), + send() {}, + on: () => () => {}, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + controller.forget(); + reply({ + open: true, + definition: DEFINITION, + model: [...create(DEFINITION).toBinary()], + revision: 0, + presence: [], + }); + await Bun.sleep(0); + expect(controller.getSnapshot().closed).toBe(true); + expect(controller.getSnapshot().drafts).toEqual({}); + off(); + }); +}); diff --git a/packages/question/src/react/use-questionnaire-option-growth.test.ts b/packages/question/src/react/use-questionnaire-option-growth.test.ts new file mode 100644 index 00000000..d97041cd --- /dev/null +++ b/packages/question/src/react/use-questionnaire-option-growth.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "bun:test"; +import { addOption as grow, create, decision } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; +import type { Transport } from "./use-questionnaire"; +import { DEFINITION } from "./use-questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks, wrappers only. +describe("QuestionnaireController option-growth", () => { + it("replays an in-flight selection after every reopen despite late older acknowledgements", async () => { + let handlers = new Map void>>(); + let definition = decision(DEFINITION); + let model = create(definition); + let opens = 0; + let edits: Array<{ patch: number[]; resolve: (reply: unknown) => void }> = []; + let wire = { + ask(kind: string, payload: Record) { + if (kind === "question:open") { + opens++; + return Promise.resolve({ + open: true, + definition, + model: [...model.toBinary()], + revision: opens - 1, + presence: [], + }); + } + if (kind === "question:edit") { + return new Promise(resolve => { + edits.push({ patch: payload.patch as number[], resolve }); + }); + } + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on(kind: string, handler: (event: never) => void) { + let set = handlers.get(kind) ?? new Set(); + handlers.set(kind, set); + set.add(handler); + return () => set.delete(handler); + }, + } as unknown as Transport; + let emitChanged = (revision: number) => { + for (let handler of handlers.get("question:changed") ?? []) { + handler({ id: "w", definition, revision } as never); + } + }; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.change("q0", { choice: "o0" }); + await Bun.sleep(0); + expect(edits).toHaveLength(1); + controller.change("q0", { choice: "o1" }); + await Bun.sleep(0); + expect(edits).toHaveLength(1); + + let first = grow(definition, model, "o9", "Roll our own"); + if (!first.ok) throw new Error("setup"); + definition = first.definition; + model = first.model; + emitChanged(1); + await Bun.sleep(10); + expect(opens).toBe(2); + expect(edits).toHaveLength(2); + expect(controller.getSnapshot().definition?.questions[0].options.map(option => option.id)) + .toContain("o9"); + expect(controller.getSnapshot().drafts.q0?.choice).toBe("o1"); + + // An acknowledgement from the abandoned generation cannot remove the + // selection that the new generation still needs to send. + edits[0]!.resolve({ open: true, accepted: true, revision: 1 }); + await Bun.sleep(0); + expect(edits).toHaveLength(2); + edits[1]!.resolve({ open: true, accepted: true, revision: 2 }); + await Bun.sleep(0); + expect(edits).toHaveLength(3); + let second = grow(definition, model, "o10", "Hosted provider"); + if (!second.ok) throw new Error("setup"); + definition = second.definition; + model = second.model; + emitChanged(2); + await Bun.sleep(10); + expect(opens).toBe(3); + expect(edits).toHaveLength(4); + edits[2]!.resolve({ open: true, accepted: true, revision: 3 }); + await Bun.sleep(0); + let third = grow(definition, model, "o11", "Bring our own keys"); + if (!third.ok) throw new Error("setup"); + definition = third.definition; + model = third.model; + emitChanged(3); + await Bun.sleep(10); + expect(opens).toBe(4); + expect(edits).toHaveLength(5); + expect(controller.getSnapshot().drafts.q0?.choice).toBe("o1"); + off(); + }); + + it("returns friendly closed and offline errors and forwards server limits", async () => { + let asked: Array<{ kind: string; payload: Record }> = []; + let result: unknown = { ok: false, message: "That is already an option" }; + let wire = { + async ask(kind: string, payload: Record) { + asked.push({ kind, payload }); + return result; + }, + send() {}, + on() { + return () => {}; + }, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + expect(await controller.addOption("Canary")).toEqual({ + ok: false, + message: "That is already an option", + }); + result = { ok: false, message: "A decision holds at most 10 options" }; + expect(await controller.addOption("Another")).toEqual({ + ok: false, + message: "A decision holds at most 10 options", + }); + result = { ok: true }; + expect(await controller.addOption("New option")).toEqual({ ok: true }); + expect(asked.map(call => call.kind)).toEqual([ + "question:add-option", + "question:add-option", + "question:add-option", + ]); + expect(asked[2]?.payload).toEqual({ id: "w", label: "New option" }); + + controller.configure(DEFINITION, false); + expect(await controller.addOption("Offline")).toMatchObject({ ok: false }); + expect(asked).toHaveLength(3); + controller.forget(); + expect(await controller.addOption("Closed")).toMatchObject({ ok: false }); + expect(asked).toHaveLength(3); + }); +}); diff --git a/packages/question/src/react/use-questionnaire-refresh-save.test.ts b/packages/question/src/react/use-questionnaire-refresh-save.test.ts new file mode 100644 index 00000000..5c84972c --- /dev/null +++ b/packages/question/src/react/use-questionnaire-refresh-save.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "bun:test"; +import { addOption as grow, create, decision } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; +import type { Transport } from "./use-questionnaire"; +import { DEFINITION } from "./use-questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks, wrappers only. +describe("QuestionnaireController refresh-save", () => { + it("saves after replayed selections settle even when an older edit never answers", async () => { + let handlers = new Map void>>(); + let definition = decision(DEFINITION); + let model = create(definition); + let opens = 0; + let edits: Array<{ resolve: (reply: unknown) => void }> = []; + let submits: number[] = []; + let wire = { + ask(kind: string, payload: Record) { + if (kind === "question:open") { + opens++; + return Promise.resolve({ + open: true, + definition, + model: [...model.toBinary()], + revision: opens - 1, + presence: [], + }); + } + if (kind === "question:edit") { + return new Promise(resolve => edits.push({ resolve })); + } + if (kind === "question:submit") { + submits.push(payload.revision as number); + return Promise.resolve({ ok: true }); + } + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on(kind: string, handler: (event: never) => void) { + let set = handlers.get(kind) ?? new Set(); + handlers.set(kind, set); + set.add(handler); + return () => set.delete(handler); + }, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + + controller.change("q0", { choice: "o0" }); + await Bun.sleep(0); + controller.change("q0", { choice: "o1" }); + controller.submit(); + await Bun.sleep(0); + expect(edits).toHaveLength(1); + off(); + await Bun.sleep(0); + + let grown = grow(definition, model, "o9", "Roll our own"); + if (!grown.ok) throw new Error("setup"); + definition = grown.definition; + model = grown.model; + for (let handler of handlers.get("question:changed") ?? []) { + handler({ id: "w", definition, revision: 1 } as never); + } + await Bun.sleep(0); + expect(opens).toBe(2); + expect(edits).toHaveLength(2); + expect(controller.getSnapshot().drafts.q0?.choice).toBe("o1"); + let remount = controller.subscribe(() => {}); + expect(opens).toBe(2); + + // Only the new generation answers. The first edit remains in flight forever. + edits[1]!.resolve({ open: true, accepted: true, revision: 2 }); + await Bun.sleep(0); + expect(edits).toHaveLength(3); + edits[2]!.resolve({ open: true, accepted: true, revision: 3 }); + await Bun.sleep(0); + expect(submits).toEqual([3]); + edits[0]!.resolve({ open: true, accepted: true, revision: 99 }); + await Bun.sleep(0); + expect(submits).toEqual([3]); + for (let handler of handlers.get("question:resolved") ?? []) handler({ id: "w" } as never); + expect(controller.getSnapshot().submitting).toBe(false); + remount(); + }); + + it("releases Save if connection drops while the replacement open is pending", async () => { + let handlers = new Map void>>(); + let opens = 0; + let wire = { + ask(kind: string) { + if (kind === "question:open") { + opens++; + return opens === 1 + ? Promise.resolve({ + open: true, + definition: DEFINITION, + model: [...create(DEFINITION).toBinary()], + revision: 0, + presence: [], + }) + : new Promise(() => {}); + } + if (kind === "question:edit") return new Promise(() => {}); + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on(kind: string, handler: (event: never) => void) { + let set = handlers.get(kind) ?? new Set(); + handlers.set(kind, set); + set.add(handler); + return () => set.delete(handler); + }, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.change("q0", { choice: "o0" }); + controller.submit(); + await Bun.sleep(0); + for (let handler of handlers.get("question:changed") ?? []) { + handler({ id: "w", definition: DEFINITION, revision: 1 } as never); + } + await Bun.sleep(0); + expect(opens).toBe(2); + off(); + await Bun.sleep(0); + controller.configure(DEFINITION, false); + await Bun.sleep(0); + expect(controller.getSnapshot().submitting).toBe(false); + expect(controller.getSnapshot().error).toBe("Could not submit these answers."); + }); +}); diff --git a/packages/question/src/react/use-questionnaire-save.test.ts b/packages/question/src/react/use-questionnaire-save.test.ts new file mode 100644 index 00000000..3b03fae5 --- /dev/null +++ b/packages/question/src/react/use-questionnaire-save.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { create } from "../index"; +import { QuestionnaireController } from "./use-questionnaire"; +import type { Transport } from "./use-questionnaire"; +import { DEFINITION, QUESTIONNAIRE, transport } from "./use-questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks, wrappers only. +describe("QuestionnaireController save", () => { + it("opens an older multi-question card with its complete shared draft", async () => { + let bridge = transport(QUESTIONNAIRE); + let controller = new QuestionnaireController( + bridge.value, + "question-1", + QUESTIONNAIRE, + true, + ); + let off = controller.subscribe(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(controller.getSnapshot().error).toBeUndefined(); + expect(controller.getSnapshot().drafts.q0).toBeDefined(); + expect(controller.getSnapshot().drafts.q1).toBeDefined(); + expect(bridge.submits()).toBe(0); + off(); + }); + + it("finishes an explicit Save after its last view unmounts", async () => { + let model = create(DEFINITION); + let settleEdit!: (reply: unknown) => void; + let submits: number[] = []; + let handlers = new Map void>>(); + let wire = { + ask(kind: string, payload: Record) { + if (kind === "question:open") { + return Promise.resolve({ + open: true, + definition: DEFINITION, + model: [...model.toBinary()], + revision: 0, + presence: [], + }); + } + if (kind === "question:edit") { + return new Promise(resolve => settleEdit = resolve); + } + if (kind === "question:submit") { + submits.push(payload.revision as number); + return Promise.resolve({ ok: true }); + } + throw new Error(`Unexpected ${kind}`); + }, + send() {}, + on(kind: string, handler: (event: never) => void) { + let set = handlers.get(kind) ?? new Set(); + handlers.set(kind, set); + set.add(handler); + return () => set.delete(handler); + }, + } as unknown as Transport; + let controller = new QuestionnaireController(wire, "w", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.change("q0", { choice: "o0" }); + controller.submit(); + await Bun.sleep(0); + off(); + await Bun.sleep(0); + settleEdit({ open: true, accepted: true, revision: 1 }); + await Bun.sleep(0); + expect(submits).toEqual([1]); + expect(controller.getSnapshot().error).toBeUndefined(); + for (let handler of handlers.get("question:resolved") ?? []) handler({ id: "w" } as never); + expect([...handlers.values()].every(set => set.size === 0)).toBe(true); + }); + + it("saves only the visible suggestion snapshot and rejects a moved or cleared one", async () => { + let current = transport(); + current.setSuggestion({ optionId: "o0", revision: 3 }); + let accepted = new QuestionnaireController(current.value, "question-1", DEFINITION, true); + let offAccepted = accepted.subscribe(() => {}); + await Bun.sleep(0); + accepted.submit({ optionId: "o0", revision: 3 }); + await Bun.sleep(0); + expect(current.submitPayloads()).toEqual([{ + id: "question-1", + revision: 3, + suggestedOptionId: "o0", + }]); + offAccepted(); + + let moved = transport(); + moved.setSuggestion({ optionId: "o0", revision: 3 }); + let stale = new QuestionnaireController(moved.value, "question-1", DEFINITION, true); + let offStale = stale.subscribe(() => {}); + await Bun.sleep(0); + moved.setSuggestion({ optionId: "o0", revision: 4 }); + stale.submit({ optionId: "o0", revision: 3 }); + await Bun.sleep(0); + expect(moved.submitPayloads()).toEqual([{ + id: "question-1", + revision: 3, + suggestedOptionId: "o0", + }]); + expect(stale.getSnapshot().submitting).toBe(false); + offStale(); + + let cleared = transport(); + cleared.setSuggestion({ optionId: "o0", revision: 3 }); + let old = new QuestionnaireController(cleared.value, "question-1", DEFINITION, true); + let offOld = old.subscribe(() => {}); + await Bun.sleep(0); + cleared.setSuggestion(undefined); + old.submit({ optionId: "o0", revision: 3 }); + await Bun.sleep(0); + expect(cleared.submitPayloads()).toEqual([{ + id: "question-1", + revision: 3, + suggestedOptionId: "o0", + }]); + expect(old.getSnapshot().submitting).toBe(false); + offOld(); + }); + + it("a queued human choice ignores an older visible suggestion", async () => { + let bridge = transport(); + bridge.setSuggestion({ optionId: "o0", revision: 3 }); + let controller = new QuestionnaireController(bridge.value, "question-1", DEFINITION, true); + let off = controller.subscribe(() => {}); + await Bun.sleep(0); + controller.change("q0", { choice: "o0" }); + controller.submit({ optionId: "o0", revision: 3 }); + await Bun.sleep(0); + expect(bridge.submitPayloads()).toEqual([{ id: "question-1", revision: 4 }]); + off(); + }); +}); diff --git a/packages/question/src/react/use-questionnaire.test-fixtures.ts b/packages/question/src/react/use-questionnaire.test-fixtures.ts new file mode 100644 index 00000000..047e8d30 --- /dev/null +++ b/packages/question/src/react/use-questionnaire.test-fixtures.ts @@ -0,0 +1,116 @@ +import { create, normalize } from "../index"; +import type { Transport } from "./use-questionnaire"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 fixture declarations. +export const DEFINITION = normalize({ + questions: [{ + header: "Rollout", + question: "How should we deploy?", + multiple: false, + options: [ + { label: "Canary", description: "Small percentage first." }, + { label: "Blue-green", description: "" }, + ], + }], +}); + +export const MULTI_QUESTIONNAIRE = normalize({ + questions: [ + { + header: "Rollout", + question: "How should we deploy?", + multiple: false, + options: [{ label: "Canary", description: "Small percentage first." }], + }, + { + header: "Timing", + question: "When should we deploy?", + multiple: false, + options: [{ label: "Tomorrow", description: "" }], + }, + ], +}); + +export const QUESTIONNAIRE = { + questions: MULTI_QUESTIONNAIRE.questions.map((question, index) => ({ + ...question, + options: question.options.map(option => ({ ...option, id: `o${index}` })), + })), +}; + +export function transport(definition = DEFINITION) { + let opens = 0; + let edits = 0; + let submits = 0; + let discards: string[] = []; + let submitIds: string[] = []; + let submitRevisions: number[] = []; + let submitPayloads: Record[] = []; + let currentRevision = 0; + let currentSuggestion: { optionId: string; revision: number } | undefined; + let presence = 0; + let handlers = new Map void>>(); + let model = create(definition); + + let value = { + async ask(kind: string, payload: Record) { + if (kind === "question:open") { + opens++; + return { + open: true, + definition, + model: [...model.toBinary()], + revision: currentRevision, + presence: [], + }; + } + if (kind === "question:edit") { + edits++; + return { open: true, accepted: true, applied: false, revision: ++currentRevision }; + } + if (kind === "question:submit") { + submits++; + submitIds.push(payload.id as string); + submitRevisions.push(payload.revision as number); + submitPayloads.push({ ...payload }); + if ( + payload.suggestedOptionId !== undefined && ( + payload.revision !== currentRevision + || payload.suggestedOptionId !== currentSuggestion?.optionId + ) + ) return { ok: false, reason: "stale", current: currentRevision }; + return { ok: true }; + } + if (kind === "question:discard") { + discards.push(payload.id as string); + return { ok: true }; + } + throw new Error(`Unexpected ${kind}`); + }, + send(kind: string) { + if (kind === "question:presence") presence++; + }, + on(kind: string, handler: (event: never) => void) { + let set = handlers.get(kind); + if (!set) handlers.set(kind, set = new Set()); + set.add(handler); + return () => set.delete(handler); + }, + } as unknown as Transport; + + return { + value, + opens: () => opens, + edits: () => edits, + submits: () => submits, + discards: () => discards, + submitIds: () => submitIds, + submitRevisions: () => submitRevisions, + submitPayloads: () => submitPayloads, + setSuggestion: (suggestion: { optionId: string; revision: number } | undefined) => { + currentSuggestion = suggestion; + currentRevision = suggestion?.revision ?? currentRevision + 1; + }, + presence: () => presence, + }; +} diff --git a/packages/question/src/react/use-questionnaire.test.ts b/packages/question/src/react/use-questionnaire.test.ts index 6852082d..0e6cef0d 100644 --- a/packages/question/src/react/use-questionnaire.test.ts +++ b/packages/question/src/react/use-questionnaire.test.ts @@ -2,21 +2,10 @@ import { describe, expect, it } from "bun:test"; import { create, normalize } from "../index"; import { QuestionnaireController } from "./use-questionnaire"; +import { DEFINITION } from "./use-questionnaire.test-fixtures"; import type { Transport } from "./use-questionnaire"; -const DEFINITION = normalize({ - questions: [{ - header: "Rollout", - question: "How should we deploy?", - multiple: false, - options: [ - { label: "Canary", description: "Small percentage first." }, - { label: "Blue-green", description: "" }, - ], - }], -}); - const QUESTIONNAIRE = normalize({ questions: [ { diff --git a/packages/question/src/react/use-questionnaire.ts b/packages/question/src/react/use-questionnaire.ts index 552691ff..dd8dd998 100644 --- a/packages/question/src/react/use-questionnaire.ts +++ b/packages/question/src/react/use-questionnaire.ts @@ -11,411 +11,15 @@ */ import { useEffect, useRef, useSyncExternalStore } from "react"; +import { QuestionnaireController } from "./questionnaire-controller"; +import type { Definition } from "../index"; +import type { QuestionnaireState, Transport } from "./questionnaire-types"; -import { derive } from "../answer"; -import { crdt } from "../draft"; -import { identified } from "../schema"; +export { QuestionnaireController }; +export type { QuestionnaireState, Transport } from "./questionnaire-types"; -import type { Definition, Drafts } from "../index"; -import type { Collaborator } from "./question-view"; - -type Unsubscribe = () => void; - -/** The slice of the Bridge this needs. */ -export type Transport = { - ask(kind: K, payload: Record): Promise; - send(kind: string, payload: Record): void; - on(kind: string, handler: (event: never) => void): Unsubscribe; -}; - -export type QuestionnaireState = { - definition: Definition | undefined; - drafts: Drafts; - collaborators: Collaborator[]; - /** True until the shared draft has arrived. */ - syncing: boolean; - submitting: boolean; - /** Validation or synchronisation problem, shown to the user. */ - error: string | undefined; - change: (question: string, change: Record) => void; - submit: () => void; - /** Decline to answer. Terminal — the agent stops waiting. */ - cancel: () => void; - /** Set when submission stopped on an unanswered question. */ - focus: string | undefined; -}; - -type Snapshot = Omit & { - closed: boolean; -}; -type Model = crdt.Model>; - -const EMPTY_DRAFTS: Drafts = Object.freeze({}); const controllers = new WeakMap>(); -function normalize(person: { - client: string; - handle?: string; - question?: string; -}): Collaborator { - return { - client: person.client, - handle: person.handle ?? "unknown", - ...(person.question ? { question: person.question } : {}), - }; -} - -export class QuestionnaireController { - readonly id: string; - readonly bridge: Transport | undefined; - - #snapshot: Snapshot; - #listeners = new Set<() => void>(); - #teardown: Unsubscribe[] = []; - #model: Model | undefined; - #definition: Definition | undefined; - #revision = 0; - #outbox: number[][] = []; - #pending = Promise.resolve(); - #generation = 0; - #refs = 0; - #connected: boolean; - #active = false; - #terminal = false; - - constructor( - bridge: Transport | undefined, - id: string, - definition: Definition | undefined, - connected: boolean, - ) { - this.bridge = bridge; - this.id = id; - this.#connected = connected; - this.#snapshot = { - definition, - drafts: EMPTY_DRAFTS, - collaborators: [], - syncing: !!bridge, - submitting: false, - error: undefined, - focus: undefined, - closed: false, - }; - } - - getSnapshot = (): Snapshot => this.#snapshot; - - subscribe = (listener: () => void): Unsubscribe => { - this.#listeners.add(listener); - this.#refs++; - if (this.#refs === 1) this.#start(); - - return () => { - this.#listeners.delete(listener); - this.#refs--; - // React Strict Mode subscribes, cleans up, then subscribes again. A - // microtask grace period turns that into one transport lifecycle rather - // than two opens with a spurious presence clear between them. - if (this.#refs === 0) { - queueMicrotask(() => { - if (this.#refs === 0) this.#stop(true); - }); - } - }; - }; - - configure(definition: Definition | undefined, connected: boolean): void { - if (definition && !this.#snapshot.definition) this.#set({ definition }); - if (connected === this.#connected) return; - this.#connected = connected; - if (!connected) { - this.#stop(false); - this.#set({ syncing: !!this.bridge, collaborators: [] }); - } else if (this.#refs > 0) { - this.#start(); - } - } - - change = (question: string, patch: Record): void => { - let doc = this.#model; - if (!doc || this.#snapshot.closed || this.#terminal || this.#snapshot.submitting) return; - this.#set({ error: undefined, focus: undefined }); - - doc.api.transaction(() => { - if (patch.mode !== undefined) doc.api.val([question, "mode"]).set(patch.mode); - if (patch.choice !== undefined) { - doc.api.val([question, "mode"]).set("choices"); - doc.api.val([question, "choice"]).set(patch.choice); - } - if (patch.options !== undefined) { - doc.api.val([question, "mode"]).set("choices"); - for (let [option, value] of Object.entries(patch.options as Record)) { - doc.api.val([question, "options", option]).set(value); - } - } - if (patch.custom !== undefined) { - let value = doc.api.str([question, "custom"]); - value.del(0, value.length()); - value.ins(0, patch.custom as string); - } - }); - }; - - submit = (): void => { - let definition = this.#definition; - let doc = this.#model; - if ( - !this.bridge || !definition || !doc || this.#snapshot.closed || this.#terminal - || this.#snapshot.submitting - ) return; - - let outcome = derive(definition, doc.view() as Drafts); - if (!outcome.ok) { - this.#set({ - focus: outcome.question, - error: outcome.message, - }); - return; - } - - this.#terminal = true; - this.#set({ submitting: true, error: undefined }); - let submit = async () => { - // The CRDT batches its change callback into a microtask. Let the final - // input event enter the outbox before choosing the revision to submit. - await Promise.resolve(); - await this.#pending; - if (this.#outbox.length > 0) throw new Error("questionnaire edits are not synchronized"); - return this.bridge!.ask("question:submit", { id: this.id, revision: this.#revision }); - }; - void submit() - .then((raw: never) => { - let reply = raw as unknown as { ok?: boolean; reason?: string; message?: string }; - if (reply.ok) return; - if (reply.reason === "stale") { - this.#terminal = false; - this.#set({ error: "Answers changed while submitting. Review and try again." }); - this.#restart(); - } else if (reply.reason === "resolved") { - this.#close(); - } else { - this.#terminal = false; - this.#set({ error: reply.message ?? "Could not submit these answers." }); - } - }) - .catch(() => { - this.#terminal = false; - this.#set({ error: "Could not submit these answers." }); - }) - .finally(() => { - if (!this.#terminal) this.#set({ submitting: false }); - }); - }; - - cancel = (): void => { - if (!this.bridge || this.#snapshot.closed || this.#terminal || this.#snapshot.submitting) { - return; - } - this.#terminal = true; - this.#set({ submitting: true, error: undefined }); - - void this.bridge.ask("question:cancel", { id: this.id }) - .then((raw: never) => { - let reply = raw as unknown as { ok?: boolean; reason?: string }; - if (reply.ok || reply.reason === "resolved") this.#close(); - else { - this.#terminal = false; - this.#set({ error: "Could not cancel this question." }); - } - }) - .catch(() => { - this.#terminal = false; - this.#set({ error: "Could not cancel this question." }); - }) - .finally(() => { - if (!this.#terminal) this.#set({ submitting: false }); - }); - }; - - forget(): void { - this.#close(); - } - - #set(patch: Partial): void { - this.#snapshot = { ...this.#snapshot, ...patch }; - for (let listener of this.#listeners) listener(); - } - - #start(): void { - if (this.#active || !this.bridge || !this.#connected || this.#snapshot.closed) return; - this.#active = true; - void this.#init(++this.#generation); - } - - #restart(): void { - this.#stop(false); - if (this.#refs > 0) this.#start(); - } - - #stop(presence: boolean): void { - if (!this.#active) return; - this.#active = false; - this.#generation++; - for (let off of this.#teardown.splice(0)) off(); - this.#model = undefined; - this.#definition = undefined; - if (presence && this.bridge && this.#connected) { - this.bridge.send("question:presence", { id: this.id }); - } - } - - #close(): void { - this.#terminal = true; - this.#outbox = []; - this.#set({ closed: true, syncing: false, submitting: false, collaborators: [] }); - this.#stop(false); - } - - async #init(generation: number): Promise { - let channel = this.bridge!; - this.#set({ syncing: true, error: undefined }); - - let buffered: number[][] = []; - let apply: ((patch: number[]) => void) | undefined; - - this.#teardown.push( - channel.on("question:edit", (raw: never) => { - let event = raw as unknown as { - id: string; - open?: boolean; - accepted?: boolean; - applied?: boolean; - revision: number; - patch: number[]; - }; - if (event.id !== this.id || !event.open || !event.accepted) return; - this.#revision = Math.max(this.#revision, event.revision); - if (!event.applied) return; - if (apply) apply(event.patch); - else buffered.push(event.patch); - }), - channel.on("question:presence", (raw: never) => { - let event = raw as unknown as { - id: string; - client: string; - handle?: string; - question?: string; - }; - if (event.id !== this.id) return; - let person = normalize(event); - let next = this.#snapshot.collaborators.filter(item => item.client !== person.client); - this.#set({ collaborators: event.question ? [...next, person] : next }); - }), - channel.on("question:resolved", (raw: never) => { - let event = raw as unknown as { id: string }; - if (event.id === this.id) this.#close(); - }), - ); - - let reply: { - open: boolean; - definition?: Definition; - model?: number[]; - revision?: number; - presence?: Array<{ - client: string; - handle?: string; - question?: string; - }>; - }; - - try { - reply = await channel.ask("question:open", { id: this.id }) as never; - } catch { - if (!this.#valid(generation)) return; - this.#set({ error: "Unable to sync shared answers." }); - return; - } - - if (!this.#valid(generation)) return; - if (!reply.open) return this.#close(); - let definition: Definition; - try { - definition = identified(reply.definition!); - } catch { - this.#set({ syncing: false, error: "Unable to sync shared answers." }); - return; - } - - let doc = crdt.Model - .fromBinary>(new Uint8Array(reply.model!)) - .fork(); - - this.#revision = Math.max(this.#revision, reply.revision ?? 0); - apply = patch => { - doc.applyPatch(crdt.Patch.fromBinary(new Uint8Array(patch))); - this.#set({ drafts: doc.view() as Drafts }); - }; - for (let patch of buffered) apply(patch); - for (let patch of this.#outbox) apply(patch); - - let send = (patch: number[]) => { - this.#pending = this.#pending.then(() => - channel.ask("question:edit", { id: this.id, patch }) - .then((raw: never) => { - let ack = raw as unknown as { - open?: boolean; - accepted?: boolean; - revision?: number; - message?: string; - }; - let index = this.#outbox.indexOf(patch); - if (index !== -1) this.#outbox.splice(index, 1); - if (!ack.open) return this.#close(); - if (!ack.accepted) { - this.#set({ - error: `${ack.message ?? "An edit was rejected"}. Syncing latest answers.`, - }); - this.#restart(); - return; - } - this.#revision = Math.max(this.#revision, ack.revision ?? 0); - }) - .catch(() => { - // Left in the outbox for the next successful open. - }) - ); - }; - - let offChanges = doc.api.onChanges.listen(() => { - this.#set({ drafts: doc.view() as Drafts }); - let patch = doc.api.flush(); - if (patch.ops.length === 0) return; - let binary = Array.from(patch.toBinary() as Uint8Array); - this.#outbox.push(binary); - send(binary); - }); - - this.#teardown.push(offChanges); - this.#model = doc; - this.#definition = definition; - for (let patch of this.#outbox) send(patch); - - this.#set({ - definition, - drafts: doc.view() as Drafts, - collaborators: (reply.presence ?? []).map(normalize), - syncing: false, - error: undefined, - }); - } - - #valid(generation: number): boolean { - return this.#active && this.#generation === generation; - } -} - function shared( bridge: Transport, id: string, @@ -432,12 +36,13 @@ function shared( return controller; } -/** Evict terminal state so an explicitly reused request id starts cleanly. */ export function forget(bridge: Transport, id: string): void { let scoped = controllers.get(bridge); let controller = scoped?.get(id); - controller?.forget(); scoped?.delete(id); + // Closing notifies mounted subscribers synchronously. Evict first so a + // subscriber that renders during that notification sees the new controller. + controller?.forget(); } export type QuestionnaireOptions = { @@ -453,16 +58,19 @@ export function useQuestionnaire(options: QuestionnaireOptions): QuestionnaireSt id: string; controller: QuestionnaireController; }>(undefined); + let current = options.bridge + ? shared(options.bridge, options.id, options.definition, options.connected) + : undefined; if ( !holder.current || holder.current.bridge !== options.bridge || holder.current.id !== options.id + || (current && holder.current.controller !== current) ) { holder.current = { bridge: options.bridge, id: options.id, - controller: options.bridge - ? shared(options.bridge, options.id, options.definition, options.connected) - : new QuestionnaireController(undefined, options.id, options.definition, false), + controller: current + ?? new QuestionnaireController(undefined, options.id, options.definition, false), }; } @@ -486,7 +94,10 @@ export function useQuestionnaire(options: QuestionnaireOptions): QuestionnaireSt error: snapshot.error, focus: snapshot.focus, change: controller.change, + addOption: controller.addOption, submit: controller.submit, + discard: controller.discard, + reopen: controller.reopen, cancel: controller.cancel, }; } diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index b84a45b6..575b349f 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -184,9 +184,16 @@ "props", "unresolved JSX props > MessagePlusIcon > ", 1 + ], + [ + "dynamic", + "*", + "props", + "unresolved JSX props > DecisionIcon > ", + 1 ] ], - "sourceHash": "0dfd0723e90646836446c9017f7dab49c8f2412c75d5d0753fa33cfd265b7f04" + "sourceHash": "395805db3113280090d3c6c652cfb28e2ff1f7ff12ec613cfdefe1b3d24e972a" }, { "file": "packages/visuals/src/ui/mini-bars.tsx", From 340dbdeee05bf7b7b03a8eb2bffcb748f34029f3 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:11:14 +0100 Subject: [PATCH 041/152] Retain suggestion edit lifecycle and original reducer regressions --- docs/jev-product-extraction.md | 8 ++ packages/question/src/react/index.ts | 9 +- .../src/react/project-suggestion.test.ts | 94 ++++++++++++++++++- .../question/src/react/project-suggestion.ts | 57 +++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index f1ee52a7..b61a40cf 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -703,3 +703,11 @@ suites are explicitly skipped without a database. Workspace and E2E TypeScript checks pass. CI passes with only two inherited lint warnings and the existing design baseline finding. All eleven research, placement, processor, browser Save, controller, editor and callback-preservation checks pass after formatting. + +The next bounded slice restores the exact suggestion-edit reducer and its two whole +original callbacks. Cancelling option composition restores an advisory suggestion +without undoing a human answer edit; text entered before a suggestion continues to +suppress replacement. All existing projection types, function, fixtures and tests +remain unchanged. The full question package passes 61 tests with 214 assertions; +focused reducer/projection coverage passes four tests with 20 assertions. Question +view rendering and card-host wiring remain separate subsequent work. diff --git a/packages/question/src/react/index.ts b/packages/question/src/react/index.ts index 1e573ae1..779d7727 100644 --- a/packages/question/src/react/index.ts +++ b/packages/question/src/react/index.ts @@ -5,8 +5,13 @@ * helpers without pulling React into a headless process. */ -export { projectSuggestion } from "./project-suggestion"; -export type { SuggestionProjection, VisibleSuggestion } from "./project-suggestion"; +export { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; +export type { + SuggestionEditAction, + SuggestionEditState, + SuggestionProjection, + VisibleSuggestion, +} from "./project-suggestion"; export { QuestionView } from "./question-view"; export type { Collaborator, QuestionStepRenderProps, QuestionViewProps } from "./question-view"; export { forget, useQuestionnaire } from "./use-questionnaire"; diff --git a/packages/question/src/react/project-suggestion.test.ts b/packages/question/src/react/project-suggestion.test.ts index 31df3d87..a38ee3af 100644 --- a/packages/question/src/react/project-suggestion.test.ts +++ b/packages/question/src/react/project-suggestion.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { projectSuggestion } from "./project-suggestion"; +import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; const AUTH = { questions: [{ @@ -45,3 +45,95 @@ test("human choices and local edits take precedence over later suggestions", () expect(queuedEdit.draft).toBe(unsent); expect(queuedEdit.suggestion).toBeUndefined(); }); + +// Whole archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("cancelling the option composer restores suggestions without undoing answer edits", () => { + let initial = { + answer: false, + composer: false, + suggestionPresent: false, + suggestionGeneration: 0, + }; + let composing = reduceSuggestionEditState(initial, { type: "composer-edited" }); + let cancelled = reduceSuggestionEditState(composing, { type: "composer-cancelled" }); + let visibleSuggestion = reduceSuggestionEditState(composing, { type: "suggestion-visible" }); + let committed = reduceSuggestionEditState(visibleSuggestion, { type: "composer-committed" }); + let clearedBeforeCommit = reduceSuggestionEditState( + reduceSuggestionEditState(visibleSuggestion, { type: "suggestion-cleared" }), + { type: "composer-committed" }, + ); + let answerThenComposer = reduceSuggestionEditState( + reduceSuggestionEditState(initial, { type: "answer-edited" }), + { type: "composer-edited" }, + ); + let answerKept = reduceSuggestionEditState(answerThenComposer, { type: "composer-cancelled" }); + let cleared = reduceSuggestionEditState(answerKept, { type: "suggestion-cleared" }); + let suggestion = { optionId: "b", revision: 7 }; + + expect(cancelled.composer).toBe(false); + expect(cancelled.answer).toBe(false); + expect(answerKept.answer).toBe(true); + expect(answerKept.composer).toBe(false); + expect(committed.answer).toBe(true); + expect(committed.composer).toBe(false); + expect(clearedBeforeCommit.answer).toBe(false); + expect(clearedBeforeCommit.composer).toBe(false); + expect(cleared.answer).toBe(false); + expect( + projectSuggestion( + AUTH.questions[0], + undefined, + suggestion, + cancelled.answer || cancelled.composer, + ) + .suggestion, + ).toBe(suggestion); + expect( + projectSuggestion( + AUTH.questions[0], + undefined, + suggestion, + answerKept.answer || answerKept.composer, + ) + .suggestion, + ).toBeUndefined(); + expect( + projectSuggestion( + AUTH.questions[0], + undefined, + suggestion, + clearedBeforeCommit.answer || clearedBeforeCommit.composer, + ) + .suggestion, + ).toBe(suggestion); +}); + +test("option composer text entered before a suggestion keeps it from replacing the edit", () => { + let initial = { + answer: false, + composer: false, + suggestionPresent: false, + suggestionGeneration: 0, + }; + let composing = reduceSuggestionEditState(initial, { type: "composer-edited" }); + let published = reduceSuggestionEditState(composing, { type: "suggestion-visible" }); + let cancelled = reduceSuggestionEditState(published, { type: "composer-cancelled" }); + let suggestion = { optionId: "b", revision: 7 }; + + expect( + projectSuggestion( + AUTH.questions[0], + undefined, + suggestion, + published.answer || published.composer, + ).suggestion, + ).toBeUndefined(); + expect( + projectSuggestion( + AUTH.questions[0], + undefined, + suggestion, + cancelled.answer || cancelled.composer, + ).suggestion, + ).toBe(suggestion); +}); diff --git a/packages/question/src/react/project-suggestion.ts b/packages/question/src/react/project-suggestion.ts index 2932f7c5..dcf0f4e2 100644 --- a/packages/question/src/react/project-suggestion.ts +++ b/packages/question/src/react/project-suggestion.ts @@ -34,3 +34,60 @@ export function projectSuggestion( suggestion, }; } + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 suggestion edit lifecycle. +export type SuggestionEditState = { + answer: boolean; + composer: boolean; + composerGeneration?: number; + suggestionPresent: boolean; + suggestionGeneration: number; +}; + +export type SuggestionEditAction = + | { type: "answer-edited" } + | { type: "composer-edited" } + | { type: "composer-cancelled" } + | { type: "composer-committed" } + | { type: "suggestion-visible" } + | { type: "suggestion-cleared" }; + +export function reduceSuggestionEditState( + state: SuggestionEditState, + action: SuggestionEditAction, +): SuggestionEditState { + switch (action.type) { + case "answer-edited": + return { ...state, answer: true }; + case "composer-edited": + return { + ...state, + composer: true, + composerGeneration: state.suggestionGeneration, + }; + case "composer-cancelled": + return { ...state, composer: false, composerGeneration: undefined }; + case "composer-committed": + if (!state.composer) return state; + return { + ...state, + answer: state.answer || ( + state.suggestionPresent + && state.composerGeneration === state.suggestionGeneration + ), + composer: false, + composerGeneration: undefined, + }; + case "suggestion-visible": + return { ...state, suggestionPresent: true }; + case "suggestion-cleared": + return { + ...state, + answer: false, + suggestionPresent: false, + suggestionGeneration: state.suggestionPresent + ? state.suggestionGeneration + 1 + : state.suggestionGeneration, + }; + } +} From a7f6bf6956e23abeff0a6308cf6c5306d1d00eef Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:19:47 +0100 Subject: [PATCH 042/152] Project advisory card choices through the existing question view --- .env.example | 8 ++ .../pipeline-lifecycle-1.test.ts | 76 +++++++++++++ .../pipeline-lifecycle-2.test.ts | 77 +++++++++++++ .../pipeline-lifecycle-3.test.ts | 88 ++++++++++++++ .../pipeline-lifecycle.test-fixtures.ts | 31 +++++ docs/jev-product-extraction.md | 24 ++++ docs/self-hosting.md | 48 ++++---- .../src/react/project-suggestion.test.ts | 13 +-- .../react/question-view-projection.test.ts | 107 ++++++++++++++++++ .../src/react/question-view.test-fixtures.ts | 14 +++ packages/question/src/react/question-view.tsx | 51 +++++++-- .../exceptions/dynamic-packages.json | 2 +- 12 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/conversation-plan/pipeline-lifecycle-1.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-lifecycle-3.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts create mode 100644 packages/question/src/react/question-view-projection.test.ts create mode 100644 packages/question/src/react/question-view.test-fixtures.ts diff --git a/.env.example b/.env.example index 157b61ea..b61c29ee 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index b61a40cf..76aa456b 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -711,3 +711,27 @@ suppress replacement. All existing projection types, function, fixtures and test remain unchanged. The full question package passes 61 tests with 214 assertions; focused reducer/projection coverage passes four tests with 20 assertions. Question view rendering and card-host wiring remain separate subsequent work. + +Seven further whole pipeline lifecycle callbacks are restored with their exact +shared helpers. They cover deliberate re-raise after discard, refusing settlement +of a decided target, human reopening review and preservation of earlier decisions. +The focused group passes seven tests with 25 assertions. Pipeline ownership is now +34 of 140 whole callbacks, with 106 assigned to subsequent groups. + +QuestionView now projects the original advisory suggestion into its existing +controls without changing the shared draft. Human edits retain priority; Save +forwards the exact displayed option/revision snapshot and single-card readiness +uses the displayed draft. Current layout, cancellation, labels, error motion, +scrolling and host step rendering remain. The original rationale callback is +retained whole; the original anatomy and `Save` caption assertions remain deferred +to their applicable card surface instead of weakening them for `Save answer`. +The original AUTH fixture now has one shared declaration. The question package +passes 69 tests with 236 assertions; SSR coverage is not mounted browser evidence. +The existing three class-forwarding cases are unchanged; only their reviewed +QuestionView owner hash is renewed. Metadata-aware card hosts remain later work. + +The original optional conversation configuration block is restored in `.env.example`. +Self-hosting lists its implemented flag, model alias, timeout and server-side key. +Current Planner model, Harness, local sign-in, credentials and deployment defaults +remain. Compose files are unchanged in this slice; container forwarding remains a +separate configuration review. No service was activated by editing examples. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 11ca1e07..d0203843 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -169,28 +169,32 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi`. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk` or `pi`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| Variable | Default | Meaning | +| ------------------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi`. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk` or `pi`). An unknown name refuses at startup. | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | +| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | +| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | +| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. diff --git a/packages/question/src/react/project-suggestion.test.ts b/packages/question/src/react/project-suggestion.test.ts index a38ee3af..977e6b6b 100644 --- a/packages/question/src/react/project-suggestion.test.ts +++ b/packages/question/src/react/project-suggestion.test.ts @@ -1,18 +1,7 @@ import { expect, test } from "bun:test"; import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; -const AUTH = { - questions: [{ - id: "q", - header: "Auth", - question: "What auth system should we use?", - multiple: false, - options: [ - { id: "a", label: "Auth0", description: "" }, - { id: "b", label: "GitHub Apps", description: "" }, - ], - }], -}; +import { AUTH } from "./question-view.test-fixtures"; test("a visible suggestion preserves its exact revision snapshot", () => { let draft = { diff --git a/packages/question/src/react/question-view-projection.test.ts b/packages/question/src/react/question-view-projection.test.ts new file mode 100644 index 00000000..b8486630 --- /dev/null +++ b/packages/question/src/react/question-view-projection.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { QuestionView } from "./question-view"; +import type { Draft } from "../draft"; + +// Original callbacks/fixture: archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// packages/question/src/react/question-view.test.ts:26. +import { AUTH } from "./question-view.test-fixtures"; + +test("an open option includes its rationale in the clickable label", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: { + questions: [{ + ...AUTH.questions[0], + options: [ + { id: "a", label: "Auth0", description: "Uses an external identity provider." }, + { id: "b", label: "GitHub Apps", description: "" }, + ], + }], + }, + drafts: {}, + })); + + let choices = markup.match(/]*>.*?<\/label>/g) ?? []; + expect(choices[0]).toContain("Auth0"); + expect(choices[0]).toContain("Uses an external identity provider."); + expect(choices[0]).toContain('class="block text-sm text-text-secondary"'); + expect(choices[0]).not.toContain('aria-hidden="true">Uses an external identity provider'); + expect(choices[1]).toContain("GitHub Apps"); + expect(choices[1]).not.toContain('class="block text-sm text-text-secondary"'); +}); + +function empty(): Draft { + return { mode: "choices", choice: null, options: { a: false, b: false }, custom: "" }; +} + +function markup(draft: Draft | undefined, optionId = "b", disabled = false) { + return renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: draft ? { q: draft } : {}, + suggested: { optionId, revision: 7 }, + onSubmit: () => {}, + disabled, + })); +} + +test("an empty single-card answer is not ready to save", () => { + let rendered = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: { q: empty() }, + onSubmit: () => {}, + })); + expect(rendered).toMatch(/]*disabled=""[^>]*>[\s\S]*?Save answer { + let draft = empty(); + let before = structuredClone(draft); + let rendered = markup(draft); + let radios = rendered.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[0]).not.toContain('checked=""'); + expect(radios[1]).toContain('checked=""'); + expect(rendered).not.toMatch(/]*disabled=""[^>]*>[\s\S]*?Save answer { + let rendered = markup({ ...empty(), choice: "a" }); + let radios = rendered.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[0]).toContain('checked=""'); + expect(radios[1]).not.toContain('checked=""'); +}); + +test("a human custom answer keeps priority over a suggestion", () => { + let rendered = markup({ ...empty(), mode: "custom", custom: "Use our own" }); + let radios = rendered.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[1]).not.toContain('checked=""'); + expect(radios[2]).toContain('checked=""'); + expect(rendered).toContain("Use our own"); +}); + +test("an unknown suggested ID cannot make an empty draft ready to save", () => { + let rendered = markup(empty(), "removed"); + expect(rendered).toMatch(/]*disabled=""[^>]*>[\s\S]*?Save answer { + let rendered = markup(empty(), "b", true); + let radios = rendered.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[1]).toContain('checked=""'); + expect(radios[1]).toContain('disabled=""'); + expect(rendered).toMatch(/]*disabled=""[^>]*>[\s\S]*?Save answer { + let rendered = renderToStaticMarkup(createElement(QuestionView, { + definition: { questions: [AUTH.questions[0]!, { ...AUTH.questions[0]!, id: "other" }] }, + drafts: { q: empty() }, + suggested: { optionId: "b", revision: 7 }, + onSubmit: () => {}, + })); + let radios = rendered.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[1]).not.toContain('checked=""'); + expect(rendered).toContain('role="tablist"'); +}); diff --git a/packages/question/src/react/question-view.test-fixtures.ts b/packages/question/src/react/question-view.test-fixtures.ts new file mode 100644 index 00000000..92a16b95 --- /dev/null +++ b/packages/question/src/react/question-view.test-fixtures.ts @@ -0,0 +1,14 @@ +// Exact AUTH fixture from archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// packages/question/src/react/question-view.test.ts. +export const AUTH = { + questions: [{ + id: "q", + header: "Auth", + question: "What auth system should we use?", + multiple: false, + options: [ + { id: "a", label: "Auth0", description: "" }, + { id: "b", label: "GitHub Apps", description: "" }, + ], + }], +}; diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 5a2cc8ab..05e14907 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -9,14 +9,16 @@ * as they arrive rather than tracking local state. */ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useReducer, useRef, useState } from "react"; import { CheckIcon, CloseIcon } from "@chopin/icons"; import { answered } from "../draft"; +import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; import type { KeyboardEvent, ReactNode } from "react"; import type { Draft, Drafts } from "../draft"; import type { Answer, Definition, Item } from "../schema"; +import type { VisibleSuggestion } from "./project-suggestion"; export type Collaborator = { /** Stable per connection, so one person on two devices shows twice. */ @@ -35,7 +37,9 @@ export type QuestionViewProps = { drafts: Drafts; /** Absent once resolved: a decision is not re-opened, a new question is asked. */ onChange?: (question: string, change: Partial) => void; - onSubmit?: () => void; + onSubmit?: (visibleSuggestion?: VisibleSuggestion) => void; + /** The current advisory card option; it never changes the shared draft. */ + suggested?: VisibleSuggestion; onCancel?: () => void; disabled?: boolean; submitting?: boolean; @@ -372,14 +376,30 @@ export function QuestionView(props: QuestionViewProps) { onQuestionLeave, onQuestionSelect, renderStep, + suggested, } = props; + let [suggestionEdits, dispatchSuggestionEdit] = useReducer(reduceSuggestionEditState, { + answer: false, + composer: false, + suggestionPresent: false, + suggestionGeneration: 0, + }); + useEffect(() => { + // A missing suggestion starts a new lifecycle; revision changes stay in the same one. + dispatchSuggestionEdit({ type: suggested ? "suggestion-visible" : "suggestion-cleared" }); + }, [suggested]); + let markHumanEdit = () => { + if (suggested) dispatchSuggestionEdit({ type: "answer-edited" }); + }; + let base = useId(); let single = definition.questions.length === 1; let [selected, setActive] = useState(() => definition.questions[0]?.id); let current = currentQuestion(definition, selected); let active = current.id; let panelId = `${base}-panel-${active}`; + if (active !== selected) setActive(active); // Cancelling cannot be undone and the agent is waiting, so it takes a // second, deliberate click rather than a modal nobody reads. @@ -410,6 +430,15 @@ export function QuestionView(props: QuestionViewProps) { ?.focus(); }, [definition, base]); + let projection = single + ? projectSuggestion( + current, + drafts[current.id], + suggested, + suggestionEdits.answer || suggestionEdits.composer, + ) + : { draft: drafts[current.id] }; + // A cancelled questionnaire has no answers, so it must be matched on status // alone — falling through would offer an editable form for a dead question. if (status === "cancelled") { @@ -556,18 +585,24 @@ export function QuestionView(props: QuestionViewProps) { onChange?.(current.id, change)} + onChange={change => { + if (single) markHumanEdit(); + onChange?.(current.id, change); + }} /> onChange?.(current.id, change)} + onChange={change => { + if (single) markHumanEdit(); + onChange?.(current.id, change); + }} /> ); @@ -650,8 +685,8 @@ export function QuestionView(props: QuestionViewProps) { {onSubmit && !confirming && (!multiple || last) && (
)} diff --git a/packages/editor/src/plan-editor.tsx b/packages/editor/src/plan-editor.tsx index 6e3b6b10..d59281e8 100644 --- a/packages/editor/src/plan-editor.tsx +++ b/packages/editor/src/plan-editor.tsx @@ -24,6 +24,7 @@ import { ResearchDraftStore } from "./research-draft"; import { register } from "./widgets"; import { widgetsPlugin } from "./widgets-plugin"; +import type { CardMetaStore } from "./card-meta"; import type { Binding } from "@lexical/yjs"; import type { MDXEditorMethods } from "@mdxeditor/editor"; import type { Plan } from "@chopin/protocol"; @@ -70,6 +71,7 @@ export type PlanEditorProps = { * editor, while the observer that finds them has to run inside it. */ questions?: QuestionnaireStore; + cardMeta?: CardMetaStore; /** Durable Research Workspace state and actions supplied by the host app. */ research?: ResearchStore; /** The same arrangement for comment threads. */ @@ -100,6 +102,7 @@ export function PlanEditor( onScrollTop, questionMotion, questions, + cardMeta, readOnly, research, scrollTop, @@ -256,6 +259,7 @@ export function PlanEditor( motionImmediately, questionMotion, questions, + cardMeta, research, researchDrafts, threads, @@ -276,6 +280,7 @@ export function PlanEditor( onChanges, binding, questions, + cardMeta, research, researchDrafts, commentPresentation, diff --git a/packages/editor/src/widget-options.ts b/packages/editor/src/widget-options.ts index 8147cedc..c1f5cc9c 100644 --- a/packages/editor/src/widget-options.ts +++ b/packages/editor/src/widget-options.ts @@ -1,5 +1,6 @@ import { Cell } from "@mdxeditor/gurx"; +import type { CardMetaStore } from "./card-meta"; import type { Binding } from "@lexical/yjs"; import type { ChangeStore } from "./changes"; import type { ContentSwapMotion } from "./content-swap"; @@ -38,6 +39,7 @@ export type WidgetOptions = { motionImmediately?: () => boolean; questionMotion?: QuestionStepMotion; questions?: QuestionnaireStore; + cardMeta?: CardMetaStore; research?: ResearchStore; researchDrafts?: ResearchDraftStore; threads?: ThreadStore; diff --git a/packages/editor/src/widgets/questionnaire-metadata.test-fixtures.ts b/packages/editor/src/widgets/questionnaire-metadata.test-fixtures.ts new file mode 100644 index 00000000..ec46beec --- /dev/null +++ b/packages/editor/src/widgets/questionnaire-metadata.test-fixtures.ts @@ -0,0 +1,28 @@ +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 fixtures, +// packages/editor/src/widgets/questionnaire.test.tsx. +export const DECIDED = { + id: "w", + thread: "t", + by: "ana", + questions: [{ + id: "q", + header: "Auth", + prompt: "What auth system should we use?", + multiple: false, + options: [{ id: "a", label: "Auth0" }, { id: "b", label: "GitHub Apps" }], + answer: "GitHub Apps", + choices: ["b"], + }], +}; + +export const META = { + status: "decided" as const, + origin: "conversation" as const, + owner: "ana", + involved: ["ana"], + history: [], + optionOrigins: {}, + hasProse: false, + refining: false, + proseOrphaned: false, +}; diff --git a/packages/editor/src/widgets/questionnaire-metadata.test.tsx b/packages/editor/src/widgets/questionnaire-metadata.test.tsx new file mode 100644 index 00000000..40995381 --- /dev/null +++ b/packages/editor/src/widgets/questionnaire-metadata.test.tsx @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { QuestionnaireCard } from "./questionnaire"; +import { DECIDED, META } from "./questionnaire-metadata.test-fixtures"; + +// Whole original callbacks235/477, archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +test("reopened metadata ignores a delayed answered projection", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: false, + connected: true, + meta: { ...META, status: "reopened" }, + value: DECIDED, + })); + expect(markup).toContain(" { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + value: { + id: "w", + by: "ana", + at: "2026-09-01T10:00:00.000Z", + questions: [{ + id: "q", + header: "Scope", + prompt: "What belongs in the first cut?", + multiple: true, + options: [{ id: "a", label: "Anchors" }], + answer: "Only collaborative anchors", + }], + }, + })); + expect(markup).toContain("Only collaborative anchors"); +}); + +test("decided metadata prevents inputs before the answer projection arrives", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + meta: META, + value: { ...DECIDED, questions: [{ ...DECIDED.questions[0]!, answer: undefined }] }, + })); + expect(markup).not.toContain(" { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + meta: { ...META, status: "discarded", resolver: "bea" }, + value: { ...DECIDED, at: "2026-09-01T10:00:00.000Z" }, + })); + expect(markup).toContain("Discarded by"); + expect(markup).toContain("@bea"); + expect(markup).not.toContain("Answered by"); + expect(markup).not.toContain("@ana"); + expect(markup).not.toContain(" { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + meta: { ...META, status: "discarded" }, + value: DECIDED, + })); + expect(markup).not.toContain("@ana"); + expect(markup).not.toContain("Answered by"); +}); + +test("decided metadata attributes its recorded resolver instead of a stale node owner", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + meta: { ...META, resolver: "bea", decidedAt: 1_000 }, + value: DECIDED, + })); + expect(markup).toContain("Answered by"); + expect(markup).toContain("@bea"); + expect(markup).not.toContain("@ana"); +}); + +test("document-only decided cards preserve current provenance", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { value: DECIDED })); + expect(markup).toContain("Answered by"); + expect(markup).toContain("@ana"); +}); + +test("chosen IDs determine resolved text while preserving the existing card layout", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + value: { ...DECIDED, questions: [{ ...DECIDED.questions[0]!, answer: "Old summary" }] }, + })); + expect(markup).toContain("GitHub Apps"); + expect(markup).not.toContain("Old summary"); +}); + +test("an open advisory suggestion projects into existing read-only controls", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: false, + connected: true, + meta: { ...META, status: "open", suggested: { optionId: "b", revision: 7, messageIds: ["m"] } }, + value: { + ...DECIDED, + questions: [{ ...DECIDED.questions[0]!, answer: undefined, choices: undefined }], + }, + })); + let radios = markup.match(/]*type="radio"[^>]*>/g) ?? []; + expect(radios[1]).toContain('checked=""'); + expect(radios[1]).toContain('disabled=""'); + expect(markup).not.toContain("Save answer"); + expect(markup).not.toContain("Cancel"); +}); diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index 5b53e49a..eaca3287 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -8,13 +8,16 @@ */ import { useEffect, useRef, useState } from "react"; +import { cardStatus } from "@chopin/dialect"; import { QuestionView, useQuestionnaire } from "@chopin/question/react"; import { useCellValue } from "@mdxeditor/gurx"; import { Provenance, SidecarCard } from "../card"; +import { useCardMeta } from "../card-meta"; import { ContentSwapLayer } from "../content-swap"; import { widgets$ } from "../widget-options"; +import type { Question } from "@chopin/protocol"; import type { ReactNode } from "react"; import type { Transport } from "@chopin/question/react"; import type { Answer } from "@chopin/question"; @@ -24,10 +27,17 @@ import type { QuestionStepMotion } from "../widget-options"; /** The plan stores the chosen text; the shared view wants answer records. */ function answers(value: Questionnaire): Answer[] | undefined { if (value.questions.some(question => question.answer === undefined)) return undefined; - return value.questions.map(question => ({ - question: question.prompt, - custom: question.answer ?? "", - })); + return value.questions.map(question => + question.choices?.length + ? { + question: question.prompt, + choices: question.options + .filter(option => question.choices!.includes(option.id)) + .map(option => option.label), + optionIds: question.choices, + } + : { question: question.prompt, custom: question.answer ?? "" } + ); } /** The document calls the question text `prompt`; the domain calls it `question`. */ @@ -49,6 +59,8 @@ function definition(value: Questionnaire) { export type QuestionnaireCardProps = { value: Questionnaire; + /** Durable lifecycle state can arrive separately from the document node. */ + meta?: Question.CardMeta; wire?: Transport; connected?: boolean; /** Whether this viewer may change or resolve the shared draft. */ @@ -70,21 +82,32 @@ export function QuestionnaireCard( onQuestionLeave, onQuestionSelect, motion, + meta, places, value, wire, }: QuestionnaireCardProps, ) { let resolved = answers(value); + let current = meta?.status ?? cardStatus(value); let pointing = { places, onQuestionEnter, onQuestionLeave, onQuestionSelect }; - return resolved - ? + return current !== "open" && current !== "reopened" + ? ( + + ) : ( } + // Metadata owns lifecycle attribution; older nodes retain their document fallback. + status={ + + } > @@ -231,12 +268,14 @@ function Decided( function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); + let meta = useCardMeta(options.cardMeta, value.id); return ( options.questions?.highlight(value.id, question)} onQuestionLeave={() => options.questions?.clear()} onQuestionSelect={question => options.questions?.reveal(value.id, question)} diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 2b00de71..266893b2 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -347,7 +347,7 @@ 2 ] ], - "sourceHash": "f0586a698b1e66d29c0ccb9e518603b23146e5166d78b695ba6405518ca20ba8" + "sourceHash": "5e5d891375f5a88ddfb333423f4fbe057aa98c8f1b1620dc1cf48015e61e0994" }, { "file": "packages/editor/src/collaboration.tsx", @@ -389,7 +389,7 @@ 1 ] ], - "sourceHash": "a893cf76153f551ec623590a097d3a760237d8c1de5ef2e2cc655d8662a55e14" + "sourceHash": "31dde2002946399e2d83f4191332a1f3ae5aa0c124f32f2da17d8b7488bcc1e5" }, { "file": "packages/editor/src/widgets/code-view.tsx", From 233162b67136c0af716bc1003ec38f3d74afe715 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:39:06 +0100 Subject: [PATCH 045/152] Restore explicit research offer controls in Conversation --- .../pipeline-option-1.test.ts | 92 ++++++ .../pipeline-option-2.test.ts | 123 +++++++ .../pipeline-option-3.test.ts | 104 ++++++ .../pipeline-option-4.test.ts | 99 ++++++ .../pipeline-option-5.test.ts | 129 ++++++++ apps/web/src/chat/chat.tsx | 4 + .../src/chat/research-offer-observer.test.ts | 99 ++++++ .../chat/research-offer-transcript.test.ts | 71 +++++ .../src/chat/research-offer.test-fixtures.ts | 68 ++++ apps/web/src/chat/research-offer.test.ts | 70 ++++ apps/web/src/chat/research-offer.tsx | 301 ++++++++++++++++++ apps/web/src/chat/transcript.tsx | 38 ++- apps/web/src/room-workspace.tsx | 113 ++++++- docs/jev-product-extraction.md | 15 + 14 files changed, 1319 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/conversation-plan/pipeline-option-1.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-option-2.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-option-3.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-option-4.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-option-5.test.ts create mode 100644 apps/web/src/chat/research-offer-observer.test.ts create mode 100644 apps/web/src/chat/research-offer-transcript.test.ts create mode 100644 apps/web/src/chat/research-offer.test-fixtures.ts create mode 100644 apps/web/src/chat/research-offer.test.ts create mode 100644 apps/web/src/chat/research-offer.tsx 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/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 698eb81b..93bca321 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -36,6 +36,7 @@ import { } from "./references"; import { Transcript } from "./transcript"; import type { TranscriptDecisions } from "./transcript"; +import type { ResearchOfferControls } from "./research-offer"; import { TerminalAlert } from "../terminal-alert"; import plannerStop from "../assets/icons/planner-stop.svg"; @@ -58,6 +59,7 @@ export type ChatProps = { onActivity?: (event: { type: "message" | "working"; busy: boolean }) => void; conversationPlan?: ConversationPlan.State; decisions?: TranscriptDecisions; + researchOffers?: ResearchOfferControls; }; export function Chat( @@ -69,6 +71,7 @@ export function Chat( onActivity, conversationPlan, decisions, + researchOffers, referencesEnabled, repository, room, @@ -282,6 +285,7 @@ export function Chat( active={active} conversationPlan={conversationPlan} decisions={decisions} + researchOffers={researchOffers} entries={entries} handle={handle} onWithdraw={id => wire?.send("chat:unqueue", { id })} 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..36674060 --- /dev/null +++ b/apps/web/src/chat/research-offer-observer.test.ts @@ -0,0 +1,99 @@ +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("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..104baf8b --- /dev/null +++ b/apps/web/src/chat/research-offer-transcript.test.ts @@ -0,0 +1,71 @@ +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, + store: {} as ResearchRequestStore, + onAction: () => {}, + }, + })); + 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..34b6f122 --- /dev/null +++ b/apps/web/src/chat/research-offer.test.ts @@ -0,0 +1,70 @@ +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) => + renderToStaticMarkup(createElement(ResearchOfferCard, { + offer, + controls: { + links: link ? { [offer.id]: link } : {}, + busy: new Set(), + errors: { [offer.id]: "Old Resume error" }, + canAct: true, + store, + onAction: () => {}, + }, + })); + 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"); +}); + +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..dcf1a963 --- /dev/null +++ b/apps/web/src/chat/research-offer.tsx @@ -0,0 +1,301 @@ +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; + store: ResearchRequestStore; + onAction: (offerId: string, choice: "research" | "dismiss" | "resume") => 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.retries = 0; + 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"; + 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 && ( + + )} +
+ )} + {busy &&

Saving…

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

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

+ )} +
+ ); +} diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 9a079213..14a3c08e 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -22,6 +22,13 @@ import type { CardMetaStore, QuestionnaireStore } from "@chopin/editor"; import type { Transport } from "@chopin/question/react"; import { ActivityLine, DecisionPrompt } from "./decision-entry"; import { ScopedChoicePrompt } from "./scoped-choice-entry"; +import { ResearchOfferCard } from "./research-offer"; +import type { ResearchOfferControls } from "./research-offer"; + +type PlanMarkers = { + conversationPlan?: ConversationPlan.State; + researchOffers?: ResearchOfferControls; +}; export type TranscriptDecisions = { questions: QuestionnaireStore; @@ -178,18 +185,21 @@ function DecisionSystemEntry( } 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 && (
@@ -216,16 +226,21 @@ function MessageBody(
)} {message.tools && message.tools.length > 0 && } + {!message.queued && markers.researchOffers && markers.conversationPlan?.researchOffers + ?.filter(offer => offer.source.messageId === message.id) + .map(offer => ( + + ))}
); } 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); @@ -251,7 +266,14 @@ function MessageGroup(
{item.messages.map(message => ( - + ))}
@@ -263,6 +285,7 @@ export function Transcript( active, conversationPlan, decisions, + researchOffers, entries, handle, onWithdraw, @@ -273,6 +296,7 @@ export function Transcript( canEdit?: boolean; conversationPlan?: ConversationPlan.State; decisions?: TranscriptDecisions; + researchOffers?: ResearchOfferControls; entries: Chat.Entry[]; handle: string; onWithdraw: (id: string) => void; @@ -333,6 +357,8 @@ export function Transcript( : : ( ()); + let researchActionScope = useRef({ + room, + wire, + connected: status === "connected", + generation: 0, + }); + if ( + researchActionScope.current.room !== room + || researchActionScope.current.wire !== wire + || researchActionScope.current.connected !== (status === "connected") + ) { + researchActionScope.current = { + room, + wire, + connected: status === "connected", + generation: researchActionScope.current.generation + 1, + }; + } + let [researchBusy, setResearchBusy] = useState>(new Set()); + let [researchErrors, setResearchErrors] = useState>({}); let [reveal, setReveal] = useState<{ widget: string; token: number }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); @@ -364,6 +393,21 @@ export function RoomWorkspace( }, [archivedAt, description, descriptionRevision, label, room, slug, updatedAt]); useEffect(() => () => research.reset(), [research]); + useEffect(() => { + pendingResearchActions.current.clear(); + setResearchBusy(new Set()); + setResearchErrors({}); + }, [room, wire, status === "connected"]); + useEffect(() => setResearchErrors({}), [conversation.state?.revision]); + useEffect(() => { + setResearchErrors(current => { + let next = Object.fromEntries( + Object.entries(current) + .filter(([id]) => researchLinks.links[id]?.status !== "linked"), + ); + return Object.keys(next).length === Object.keys(current).length ? current : next; + }); + }, [researchLinks.links]); useEffect(() => { cardMeta.listen(undefined); @@ -449,6 +493,63 @@ export function RoomWorkspace( let workspaceArchivedAt = archivedAt ?? metadata.archivedAt; let workspaceCanEdit = effectiveCanEdit && !workspaceArchivedAt; + let actOnResearchOffer = ( + offerId: string, + choice: "research" | "dismiss" | "resume", + ) => { + if (!wire?.connected || status !== "connected" || !workspaceCanEdit) return; + let offer = conversationStore.get().state?.researchOffers?.find(item => item.id === offerId); + if ( + !offer || (choice === "resume" ? offer.status !== "accepted" : offer.status !== "offered") + ) { + return; + } + if ( + choice === "resume" + && latestResearchLinks.current[offerId]?.status !== "pending" + && latestResearchLinks.current[offerId]?.status !== "unlinked" + ) return; + if (pendingResearchActions.current.has(offerId)) return; + pendingResearchActions.current.add(offerId); + setResearchBusy(new Set(pendingResearchActions.current)); + setResearchErrors(current => { + let next = { ...current }; + delete next[offerId]; + return next; + }); + let payload = choice === "resume" + ? { offerId, choice } + : { offerId, choice, actionId: crypto.randomUUID() }; + let generation = researchActionScope.current.generation; + let currentScope = () => researchActionScope.current.generation === generation; + void wire.ask( + "conversation-plan:research", + payload, + ).then(result => { + if (!currentScope()) return; + if (result.offerId !== offerId) throw new Error("research action did not match the offer"); + if (result.status === "accepted") researchLinks.refresh(offerId, true); + }).catch(() => { + if (!currentScope()) return; + let currentOffer = conversationStore.get().state?.researchOffers + ?.find(item => item.id === offerId); + if ( + !shouldShowResearchActionError( + choice, + currentOffer?.status, + latestResearchLinks.current[offerId]?.status, + ) + ) return; + setResearchErrors(current => ({ + ...current, + [offerId]: "Research status could not be confirmed. Try again when connected.", + })); + }).finally(() => { + if (!currentScope()) return; + pendingResearchActions.current.delete(offerId); + setResearchBusy(new Set(pendingResearchActions.current)); + }); + }; if (deleted) { return (
@@ -467,6 +568,16 @@ export function RoomWorkspace( handle={handle} onActivity={onChatActivity} conversationPlan={conversation.state} + researchOffers={conversation.enabled + ? { + links: researchLinks.links, + busy: researchBusy, + errors: researchErrors, + canAct: status === "connected" && !!wire?.connected && !!workspaceCanEdit, + store: research, + onAction: actOnResearchOffer, + } + : undefined} decisions={{ questions, meta: cardMeta, diff --git a/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index bbba7f1d..0228bb35 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -766,3 +766,18 @@ assertions across 420 files and no failures. Two PostgreSQL suites skip without database. Full workspace/E2E type checks and CI pass, retaining only the two inherited lint warnings and the existing design baseline finding. Exact callback, component and RoomWorkspace source-preservation proofs pass after formatting. + +The original research-offer module and all eight whole test callbacks are restored. +Offers appear under their exact source message, exclude queued messages and preserve +the escaped public brief. Read-only link observers retain their bounded retry and +stale-reply cleanup; they never start or resume research. The explicit workspace +action retains writer/connection checks, live offer/link checks, duplicate suppression +and the room/socket/connection generation fence. Current Chat, transcript grouping, +composer, scrolling, decision entries and authentication behavior are preserved. + +Twelve further whole option-provenance pipeline callbacks retain their exact helpers +and public entry point. Unique retention is 54 of 140 callbacks, with 86 remaining. +The focused chat, research-store and pipeline run passes 122 tests with 432 assertions. +Full workspace/E2E types and CI pass with the inherited warnings/baseline only. Whole +module, callback, controls and three-file integration source proofs pass after +formatting. Mounted hooks and browser reconnect timing remain unverified. From 6ed0a4096ba98141f8caf77740dfbde991f47a10 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:46:34 +0100 Subject: [PATCH 046/152] Restore card option composer and original provenance cases --- .../pipeline-recommendation-1.test.ts | 65 +++++++++ .../pipeline-recommendation-2.test.ts | 61 +++++++++ .../pipeline-recommendation-3.test.ts | 117 ++++++++++++++++ .../pipeline-recommendation-4.test.ts | 104 ++++++++++++++ .../pipeline-recommendation-5.test.ts | 87 ++++++++++++ .../pipeline-recommendation-6.test.ts | 78 +++++++++++ .../pipeline-recommendation-7.test.ts | 78 +++++++++++ docs/jev-product-extraction.md | 16 +++ packages/editor/src/widgets/questionnaire.tsx | 2 + .../question/src/react/add-option.test.ts | 61 +++++++++ packages/question/src/react/add-option.tsx | 129 ++++++++++++++++++ packages/question/src/react/question-view.tsx | 25 ++++ .../exceptions/dynamic-editor.json | 2 +- .../exceptions/dynamic-packages.json | 2 +- 14 files changed, 825 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-1.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-3.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-4.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-5.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-7.test.ts create mode 100644 packages/question/src/react/add-option.test.ts create mode 100644 packages/question/src/react/add-option.tsx 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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 0228bb35..0f176edf 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -781,3 +781,19 @@ The focused chat, research-store and pipeline run passes 122 tests with 432 asse Full workspace/E2E types and CI pass with the inherited warnings/baseline only. Whole module, callback, controls and three-file integration source proofs pass after formatting. Mounted hooks and browser reconnect timing remain unverified. + +The original AddOption composer is restored as a private leaf. Single-card views +keep their existing Choices, Custom, Cancel, Save caption and layout; adding an +option forwards through the existing shared controller under the edit guard. The +original limit, temporary-lock, absent-handler, keyboard-attempt and suggestion +edit ownership logic is preserved. Four whole original view callbacks are retained, +bringing that suite to 12 of 27 callbacks with 15 still assigned to later surfaces. +Browser focus, keyboard and viewport behavior remain unverified. + +Fourteen further whole pipeline callbacks cover research quote selection, pending +targeting, owned recommendations, ambiguity, tentative leaning and mixed source +ranges. Unique retention is 68 of 140, with 72 remaining. The focused question, +card and pipeline run passes 101 tests with 356 assertions. Full workspace/E2E +types and CI pass; only two existing reviewed component owner hashes change, +with all exception cases retained. Original leaf, callback and integration proofs +pass after formatting. Existing lint warnings and design baseline remain. diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index eaca3287..e129a6b6 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -212,6 +212,8 @@ function Undecided( error={state.error} errorClassName="editor-motion-feedback" suggested={meta?.suggested} + showActions + onAddOption={editable ? state.addOption : undefined} onCancel={editable ? state.cancel : undefined} onChange={editable ? state.change : undefined} onSubmit={editable ? state.submit : undefined} diff --git a/packages/question/src/react/add-option.test.ts b/packages/question/src/react/add-option.test.ts new file mode 100644 index 00000000..3317f572 --- /dev/null +++ b/packages/question/src/react/add-option.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QuestionView } from "./question-view"; +import { AUTH } from "./question-view.test-fixtures"; + +// Whole original269/494/509/521 callbacks, archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +test("a multi-question card does not offer adding options", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: { + questions: [AUTH.questions[0], { + id: "scope", + header: "Scope", + question: "What belongs in the first cut?", + multiple: true, + options: [{ id: "anchors", label: "Anchors", description: "" }], + }], + }, + drafts: {}, + onAddOption: async () => ({ ok: true as const }), + })); + expect(markup).not.toContain("Add another option"); +}); + +test("Add another is hidden at the option limit", () => { + let options = Array.from({ length: 10 }, (_, index) => ({ + id: `o${index}`, + label: `Option ${index}`, + description: "", + })); + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: { questions: [{ ...AUTH.questions[0], options }] }, + drafts: {}, + onAddOption: async () => ({ ok: true as const }), + })); + + expect(markup).not.toContain("Add another option"); +}); + +test("a temporarily locked composer trigger remains available to reopen read-only", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + disabled: true, + onAddOption: async () => ({ ok: true as const }), + })); + + expect(markup).toMatch(/]*aria-label="Add another option"[^>]*>/); + expect(markup).not.toMatch(/]*aria-label="Add another option"[^>]*disabled=""/); +}); + +test("a composer trigger without an add handler is natively disabled", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + disabled: true, + showActions: true, + })); + + expect(markup).toMatch(/]*aria-label="Add another option"[^>]*disabled=""/); +}); diff --git a/packages/question/src/react/add-option.tsx b/packages/question/src/react/add-option.tsx new file mode 100644 index 00000000..62e93950 --- /dev/null +++ b/packages/question/src/react/add-option.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "react"; +import { PlusIcon } from "@chopin/icons"; + +export function AddOption( + { disabled, onAdd, onCancel, onCommit, onEdit }: { + disabled: boolean; + onAdd?: (label: string) => Promise<{ ok: true } | { ok: false; message: string }>; + onCancel?: () => void; + onCommit?: () => void; + onEdit?: () => void; + }, +) { + let [open, setOpen] = useState(false); + let [value, setValue] = useState(""); + let [error, setError] = useState(); + let [busy, setBusy] = useState(false); + let field = useRef(null); + let trigger = useRef(null); + let focusField = useRef(false); + let focusTrigger = useRef(false); + let attempt = useRef(0); + let locked = disabled || busy || !onAdd; + + useEffect(() => { + if (!open) { + if (!focusTrigger.current) return; + focusTrigger.current = false; + trigger.current?.focus(); + return; + } + if (!focusField.current) return; + focusField.current = false; + field.current?.focus(); + }, [open]); + + useEffect(() => { + let viewport = window.visualViewport; + if (!viewport) return; + let height = viewport.height; + let reveal = () => { + let previous = height; + height = viewport.height; + let control = field.current; + if (height >= previous || document.activeElement !== control || !control) return; + let bounds = control.getBoundingClientRect(); + let top = viewport.offsetTop; + let bottom = top + viewport.height; + if (bounds.top >= top && bounds.bottom <= bottom) return; + requestAnimationFrame(() => control.scrollIntoView({ block: "nearest" })); + }; + + viewport.addEventListener("resize", reveal); + return () => viewport.removeEventListener("resize", reveal); + }, []); + + if (!open) { + return ( + + ); + } + + return ( +
+ { + if (locked) return; + setValue(event.currentTarget.value); + setError(undefined); + onEdit?.(); + }} + onKeyDown={async event => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + attempt.current++; + focusField.current = false; + focusTrigger.current = true; + setOpen(false); + setValue(""); + setError(undefined); + onCancel?.(); + return; + } + if (event.key !== "Enter" || locked || !onAdd) return; + event.preventDefault(); + let current = ++attempt.current; + setBusy(true); + let result: { ok: true } | { ok: false; message: string }; + try { + result = await onAdd(value); + } catch { + result = { ok: false, message: "Could not add it." }; + } + setBusy(false); + if (current !== attempt.current) return; + if (result.ok) { + setValue(""); + onCommit?.(); + } else setError(result.message); + }} + placeholder="Type an option and press Enter" + readOnly={locked} + ref={field} + value={value} + /> + {error &&

{error}

} +
+ ); +} diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 05e14907..3b865de0 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -13,6 +13,8 @@ import { useCallback, useEffect, useId, useReducer, useRef, useState } from "rea import { CheckIcon, CloseIcon } from "@chopin/icons"; import { answered } from "../draft"; +import * as limits from "../limits"; +import { AddOption } from "./add-option"; import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; import type { KeyboardEvent, ReactNode } from "react"; @@ -35,11 +37,16 @@ export type QuestionStepRenderProps = { export type QuestionViewProps = { definition: Definition; drafts: Drafts; + /** Keeps open-card actions visible when the host withholds mutation callbacks. */ + showActions?: boolean; /** Absent once resolved: a decision is not re-opened, a new question is asked. */ onChange?: (question: string, change: Partial) => void; onSubmit?: (visibleSuggestion?: VisibleSuggestion) => void; /** The current advisory card option; it never changes the shared draft. */ suggested?: VisibleSuggestion; + /** Adds an option through the record that owns this decision. */ + onAddOption?: (label: string) => Promise<{ ok: true } | { ok: false; message: string }>; + maxOptions?: number; onCancel?: () => void; disabled?: boolean; submitting?: boolean; @@ -359,9 +366,12 @@ export function QuestionView(props: QuestionViewProps) { let { definition, drafts, + showActions = false, onChange, onSubmit, onCancel, + onAddOption, + maxOptions = limits.MAX_DECISION_OPTIONS, disabled = false, submitting = false, status = "open", @@ -393,6 +403,12 @@ export function QuestionView(props: QuestionViewProps) { if (suggested) dispatchSuggestionEdit({ type: "answer-edited" }); }; + let markComposerEdit = () => { + dispatchSuggestionEdit({ type: "composer-edited" }); + }; + let cancelComposerEdit = () => dispatchSuggestionEdit({ type: "composer-cancelled" }); + let commitComposerEdit = () => dispatchSuggestionEdit({ type: "composer-committed" }); + let base = useId(); let single = definition.questions.length === 1; let [selected, setActive] = useState(() => definition.questions[0]?.id); @@ -604,6 +620,15 @@ export function QuestionView(props: QuestionViewProps) { onChange?.(current.id, change); }} /> + {single && (onAddOption || showActions) && current.options.length < maxOptions && ( + + )} ); diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 266893b2..27d1bb44 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -389,7 +389,7 @@ 1 ] ], - "sourceHash": "31dde2002946399e2d83f4191332a1f3ae5aa0c124f32f2da17d8b7488bcc1e5" + "sourceHash": "6a7e3be204c6197219b17d836655ea9cc8f2cb07941811b0349c68124c6e950f" }, { "file": "packages/editor/src/widgets/code-view.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 5bdffa29..18d6447e 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -25,7 +25,7 @@ 1 ] ], - "sourceHash": "307cc16ff03560f931672b369a7246f4409d7e738c575a8c3451de2235561836" + "sourceHash": "cc53750d677cfff5040520cb8d8ad316a88f184bbc2a4df1d1bf8558728ecd86" }, { "file": "packages/icons/src/icon.tsx", From 369ecb5dd3f6134a852e39c29c5d14153373d920 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:11 +0100 Subject: [PATCH 047/152] Retain evidence projection and original commitment safeguards --- .../pipeline-commitment-ownership.test.ts | 77 ++++++ .../pipeline-commitment-pending.test.ts | 103 ++++++++ .../pipeline-commitment-reason.test.ts | 121 +++++++++ .../pipeline-commitment-triage.test.ts | 81 ++++++ .../pipeline-commitment.test-fixtures.ts | 31 +++ .../pipeline-commitment.test.ts | 108 ++++++++ .../src/conversation-plan/evidence.test.ts | 247 ++++++++++++++++++ apps/web/src/conversation-plan/evidence.ts | 109 ++++++++ docs/jev-product-extraction.md | 15 ++ 9 files changed, 892 insertions(+) create mode 100644 apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-commitment.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/pipeline-commitment.test.ts create mode 100644 apps/web/src/conversation-plan/evidence.test.ts create mode 100644 apps/web/src/conversation-plan/evidence.ts 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/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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index 0f176edf..a5e009ec 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -797,3 +797,18 @@ card and pipeline run passes 101 tests with 356 assertions. Full workspace/E2E types and CI pass; only two existing reviewed component owner hashes change, with all exception cases retained. Original leaf, callback and integration proofs pass after formatting. Existing lint warnings and design baseline remain. + +The pure evidence projection and its eight complete original callbacks are restored +byte-for-byte. It groups current supporters, opposers and targeted reasons or +constraints in card order, keeps general evidence last, and retains the original +Planner rationale/source fallback. Historical stances, neutral or unknown-option +stances and empty rationale do not manufacture visible evidence. Popover and source +highlighting integration remain subsequent work; no browser geometry was exercised. + +Twelve further whole pipeline callbacks restore exact question triage, clause +isolation, causal evidence and commitment safeguards. The two missing original +commitment helpers are retained once in a natural private fixture. Unique retention +is 80 of 140 callbacks, with 60 remaining. The focused evidence/pipeline run passes +20 tests with 134 assertions. Full workspace/E2E types and CI pass with the inherited +warnings/baseline only; producer, complete callback, table and helper proofs pass +after formatting. No interpretation policy was changed. From f60a724956eff2da7c6c3b8e1e6550221ef9e6ad Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:01:14 +0100 Subject: [PATCH 048/152] Restore terminal card actions and original source safeguards --- .../pipeline-source-fallback.test.ts | 186 ++++++++++++++++++ .../pipeline-source-opening.test.ts | 36 ++++ .../pipeline-source-quotes.test.ts | 119 +++++++++++ .../pipeline-source-recovery.test.ts | 63 ++++++ .../pipeline-source-targeting.test.ts | 111 +++++++++++ docs/jev-product-extraction.md | 19 ++ .../widgets/questionnaire-lifecycle.test.tsx | 81 ++++++++ packages/editor/src/widgets/questionnaire.tsx | 49 ++++- packages/question/src/react/question-view.tsx | 46 ++++- .../src/react/resolved-actions.test.ts | 84 ++++++++ .../question/src/react/resolved-actions.tsx | 75 +++++++ .../exceptions/dynamic-editor.json | 2 +- .../exceptions/dynamic-packages.json | 16 +- 13 files changed, 872 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/conversation-plan/pipeline-source-fallback.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-source-opening.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-source-quotes.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-source-recovery.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-source-targeting.test.ts create mode 100644 packages/editor/src/widgets/questionnaire-lifecycle.test.tsx create mode 100644 packages/question/src/react/resolved-actions.test.ts create mode 100644 packages/question/src/react/resolved-actions.tsx 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..ece0a332 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts @@ -0,0 +1,119 @@ +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 valid D03 alternatives when the topic says %s", text => { + expect(extractQuotes(text).map(({ quote }) => quote)).toEqual([ + "SMTP relay", + "Postmark", + "SES", + ]); +}); + +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/docs/jev-product-extraction.md b/docs/jev-product-extraction.md index a5e009ec..f37bb6a2 100644 --- a/docs/jev-product-extraction.md +++ b/docs/jev-product-extraction.md @@ -812,3 +812,22 @@ is 80 of 140 callbacks, with 60 remaining. The focused evidence/pipeline run pas 20 tests with 134 assertions. Full workspace/E2E types and CI pass with the inherited warnings/baseline only; producer, complete callback, table and helper proofs pass after formatting. No interpretation policy was changed. + +The original terminal Reopen/Discard control leaf and four whole view callbacks +are restored. Explicit requests retain the original duplicate-request fence and +writer/connection/discard guards; metadata continues to own lifecycle transitions +and attribution. Missing terminal answers render Saved decision instead of inputs. +Current open-state layout, Cancel, error motion and Save caption are preserved. +QuestionView retention is 16 of 27 callbacks, with 11 still assigned. The original +editor presentation-list callback remains deferred whole; no ignored compatibility +prop was introduced. The inherited request helper has no new generation fence. + +Fifteen further whole source/fallback registrations retain complete parameter +tables, exact UTF-16 clauses, pending qualifiers, context and D01 recovery gates. +They execute 30 cases; unique pipeline retention is 95 of 140, with 45 remaining. +The focused view/card/pipeline run passes 125 tests with 432 assertions. Full +workspace/E2E types and CI pass with inherited warnings/baseline only. Design review +adds one exact owner-hashed lifecycle footer forwarding case and updates the existing +error-class case count for its terminal use; all other cases remain. Source, callback, +table and request-helper proofs pass after formatting. Mounted browser behavior +remains unverified. diff --git a/packages/editor/src/widgets/questionnaire-lifecycle.test.tsx b/packages/editor/src/widgets/questionnaire-lifecycle.test.tsx new file mode 100644 index 00000000..5c5fe0f7 --- /dev/null +++ b/packages/editor/src/widgets/questionnaire-lifecycle.test.tsx @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { QuestionnaireCard } from "./questionnaire"; +import { DECIDED, META } from "./questionnaire-metadata.test-fixtures"; + +let wire = { ask: async () => ({ ok: true }) } as never; + +test("terminal lifecycle actions require current edit permission and a connected wire", () => { + let editable = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: true, + connected: true, + meta: META, + value: DECIDED, + wire, + })); + expect(editable).toContain(">Reopen<"); + expect(editable).toContain(">Discard<"); + expect(editable).not.toMatch(/]*disabled=""[^>]*>ReopenReopen<"); + expect(markup).not.toContain(">Discard<"); + } +}); + +test("current metadata governs lifecycle actions despite a stale discarded projection", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: true, + connected: true, + meta: { ...META, resolver: "bea" }, + value: { ...DECIDED, status: "discarded" }, + wire, + })); + expect(markup).toContain(">Reopen<"); + expect(markup).toContain(">Discard<"); + expect(markup).toContain("Answered by"); + expect(markup).toContain("@bea"); + expect(markup).not.toContain("@ana"); +}); + +test("discarded metadata retires lifecycle actions and the old projection actor", () => { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: true, + connected: true, + meta: { ...META, status: "discarded", resolver: "bea" }, + value: DECIDED, + wire, + })); + expect(markup).not.toContain(">Reopen<"); + expect(markup).not.toContain(">Discard<"); + expect(markup).toContain("Discarded by"); + expect(markup).toContain("@bea"); + expect(markup).not.toContain("@ana"); + expect(markup).not.toContain(" { + let markup = renderToStaticMarkup(createElement(QuestionnaireCard, { + canEdit: true, + connected: true, + meta: META, + value: { ...DECIDED, questions: [{ ...DECIDED.questions[0]!, answer: undefined }] }, + wire, + })); + expect(markup).toContain("Saved decision"); + expect(markup).toContain(">Reopen<"); + expect(markup).not.toContain(" @@ -233,13 +236,46 @@ function Undecided( } function Decided( - { discarded, meta, resolved, value, ...pointing }: { + { canEdit, connected, discarded, meta, resolved, value, wire, ...pointing }: { + canEdit: boolean; + connected: boolean; discarded: boolean; meta?: Question.CardMeta; - resolved: Answer[]; + resolved: Answer[] | undefined; value: Questionnaire; + wire?: Transport; } & Pointing, ) { + let [error, setError] = useState(); + let [submitting, setSubmitting] = useState(false); + let pending = useRef(false); + let editable = canEdit && connected && !!wire && !discarded; + let request = (kind: "question:reopen" | "question:discard") => { + if (!wire || pending.current) return; + pending.current = true; + setSubmitting(true); + setError(undefined); + void wire.ask(kind, { id: value.id }) + .then((reply: unknown) => { + if ((reply as { ok?: boolean }).ok) return; + setError( + kind === "question:reopen" + ? "Could not reopen it. Try again." + : "Could not discard this decision.", + ); + }) + .catch(() => + setError( + kind === "question:reopen" + ? "Could not reopen it. Try again." + : "Could not discard this decision.", + ) + ) + .finally(() => { + pending.current = false; + setSubmitting(false); + }); + }; return ( request("question:discard") : undefined} + onReopen={editable ? () => request("question:reopen") : undefined} resolver={discarded ? meta?.resolver : undefined} status={discarded ? "cancelled" : "answered"} + submitting={submitting} {...pointing} /> diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 3b865de0..c8040614 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -15,6 +15,7 @@ import { CheckIcon, CloseIcon } from "@chopin/icons"; import { answered } from "../draft"; import * as limits from "../limits"; import { AddOption } from "./add-option"; +import { ResolvedActions } from "./resolved-actions"; import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; import type { KeyboardEvent, ReactNode } from "react"; @@ -48,6 +49,9 @@ export type QuestionViewProps = { onAddOption?: (label: string) => Promise<{ ok: true } | { ok: false; message: string }>; maxOptions?: number; onCancel?: () => void; + onDiscard?: () => void; + /** Reopen a resolved decision without opening a shared draft in this view. */ + onReopen?: () => void; disabled?: boolean; submitting?: boolean; status?: "open" | "answered" | "cancelled"; @@ -370,6 +374,8 @@ export function QuestionView(props: QuestionViewProps) { onChange, onSubmit, onCancel, + onDiscard, + onReopen, onAddOption, maxOptions = limits.MAX_DECISION_OPTIONS, disabled = false, @@ -467,19 +473,41 @@ export function QuestionView(props: QuestionViewProps) { ); } - if (status !== "open" && answers) { + if (status !== "open") { return (
{single && } {aside} - + ) + :

Saved decision

} + {error && ( +

+ {error} +

+ )} +
); diff --git a/packages/question/src/react/resolved-actions.test.ts b/packages/question/src/react/resolved-actions.test.ts new file mode 100644 index 00000000..543e3a07 --- /dev/null +++ b/packages/question/src/react/resolved-actions.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QuestionView } from "./question-view"; +import { AUTH } from "./question-view.test-fixtures"; + +// Whole original160/178/190/203 callbacks, archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +test("a decided card offers Reopen and Discard when allowed", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + status: "answered", + answers: [{ + question: "What auth system should we use?", + choices: ["GitHub Apps"], + optionIds: ["b"], + }], + resolver: "ana", + onReopen: () => {}, + onDiscard: () => {}, + })); + expect(markup).toContain(">Reopen<"); + expect(markup).toContain(">Discard<"); +}); + +test("a decided card announces a reopen or discard failure", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + status: "answered", + answers: [{ question: "What auth system should we use?", choices: ["GitHub Apps"] }], + error: "Could not reopen it. Try again.", + })); + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Could not reopen it. Try again."); +}); + +test("a decided card without its projected answer stays non-editable", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + status: "answered", + onReopen: () => {}, + onDiscard: () => {}, + })); + expect(markup).toContain("Saved decision"); + expect(markup).not.toContain("Reopen<"); +}); + +test("a resolved legacy multi-card keeps actions and errors", () => { + let definition = { + questions: [AUTH.questions[0], { + id: "scope", + header: "Scope", + question: "What belongs in the first cut?", + multiple: true, + options: [{ id: "anchors", label: "Anchors", description: "" }], + }], + }; + let editable = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: {}, + status: "answered", + answers: [ + { question: AUTH.questions[0].question, choices: ["GitHub Apps"] }, + { question: "What belongs in the first cut?", choices: ["Anchors"] }, + ], + error: "Could not discard this decision.", + onReopen: () => {}, + onDiscard: () => {}, + })); + let reader = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: {}, + status: "answered", + answers: [{ question: AUTH.questions[0].question, choices: ["GitHub Apps"] }], + })); + expect(editable).toContain(">Reopen<"); + expect(editable).toContain(">Discard<"); + expect(editable).toContain('role="alert"'); + expect(reader).not.toContain(">Reopen<"); + expect(reader).not.toContain(">Discard<"); +}); diff --git a/packages/question/src/react/resolved-actions.tsx b/packages/question/src/react/resolved-actions.tsx new file mode 100644 index 00000000..1974caf2 --- /dev/null +++ b/packages/question/src/react/resolved-actions.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; + +export function ResolvedActions( + { className, disabled, onDiscard, onReopen, submitting }: { + className?: string; + disabled: boolean; + onDiscard?: () => void; + onReopen?: () => void; + submitting: boolean; + }, +) { + let [confirming, setConfirming] = useState(false); + let keep = () => setConfirming(false); + let beginResolvedDiscard = () => setConfirming(true); + let confirmResolvedDiscard = () => onDiscard?.(); + if (!onDiscard && !onReopen) return null; + + return ( +
+ {onDiscard && confirming + ? ( + <> +

Discard this decision?

+ + + + ) + : ( + <> + {onDiscard && ( + + )} + {onReopen && ( + + )} + + )} +
+ ); +} diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 27d1bb44..3ca4429f 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -389,7 +389,7 @@ 1 ] ], - "sourceHash": "6a7e3be204c6197219b17d836655ea9cc8f2cb07941811b0349c68124c6e950f" + "sourceHash": "b786451f8c6149b801117869d732254906627f5882dac2738b54af76c6b7ef58" }, { "file": "packages/editor/src/widgets/code-view.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 18d6447e..0fe68aa3 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -22,10 +22,24 @@ "class", "errorClassName", "class > QuestionView >

", + 2 + ] + ], + "sourceHash": "2277b23028e0d32d42db7d39e415c7ac4070836ac32c3e9b3d18fb053f01a65a" + }, + { + "file": "packages/question/src/react/resolved-actions.tsx", + "reason": "Typed lifecycle footer forwards caller class names; static call-site classes remain scanned at their owner.", + "cases": [ + [ + "dynamic", + "class", + "className", + "class > ResolvedActions >