From 6ac4229afa176009458773c9540883fbaf1da579 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:58:19 +0100 Subject: [PATCH] Add durable conversation processor and runtime --- .../job-authenticated-memory.test-fixtures.ts | 97 ++++ .../src/chat/job-card-memory.test-fixtures.ts | 131 +++++ apps/server/src/chat/job-card-memory.test.ts | 54 ++ .../src/chat/job-heading-fencing.test.ts | 134 +++++ .../chat/job-heading-memory.test-fixtures.ts | 69 +++ .../src/chat/job-heading-memory.test.ts | 75 +++ .../src/chat/job-prompt-card.test-fixtures.ts | 145 +++++ apps/server/src/chat/job-prompt-prose.test.ts | 176 ++++++ ...b-prompt-scripted-harness.test-fixtures.ts | 156 ++++++ .../chat/job-prompt-scripted-harness.test.ts | 150 +++++ .../src/chat/job-prompt-suggest.test.ts | 146 +++++ .../job-scripted-harness.test-fixtures.ts | 122 ++++ .../src/chat/job-scripted-harness.test.ts | 185 ++++++ .../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.test.ts | 5 + 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 | 91 +++ .../conversation-plan/card-mirror-runner.ts | 43 ++ .../src/conversation-plan/card-mirror.ts | 62 ++ .../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 | 172 ++++++ .../src/conversation-plan/cards-9.test.ts | 105 ++++ .../conversation-plan/cards.test-fixtures.ts | 194 +++++++ apps/server/src/conversation-plan/cards.ts | 35 ++ .../src/conversation-plan/job-context.test.ts | 326 +++++++++++ .../src/conversation-plan/job-context.ts | 162 ++++++ .../job-prompts-sources.test.ts | 114 ++++ .../src/conversation-plan/job-prompts.test.ts | 115 ++++ .../src/conversation-plan/job-prompts.ts | 186 ++++++ ...job-scripted-refine-retry.test-fixtures.ts | 172 ++++++ .../job-scripted-refine-retry.test.ts | 230 ++++++++ .../option-admission.test.ts | 530 ++++++++++++++++++ .../pipeline-recommendation-1.test.ts | 65 +++ .../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 | 109 ++++ .../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 ++++++++ .../processor-card-context.ts | 33 ++ .../conversation-plan/processor-commands.ts | 185 ++++++ .../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 | 199 +++++++ .../processor-scoped-save.ts | 115 ++++ .../src/conversation-plan/processor-types.ts | 43 ++ .../prompt-scripted-harness.test.ts | 244 ++++++++ ...esearch-offer-detection-card-focus.test.ts | 242 ++++++++ ...-offer-detection-comparison-guards.test.ts | 351 ++++++++++++ ...rch-offer-detection-concern-guards.test.ts | 257 +++++++++ .../research-offer-detection-positive.test.ts | 282 ++++++++++ .../research-offer-detection.test-fixtures.ts | 348 ++++++++++++ .../runtime-acceptance.test.ts | 85 +++ .../conversation-plan/runtime-card.test.ts | 132 +++++ .../runtime-lifecycle.test.ts | 179 ++++++ .../conversation-plan/runtime-save.test.ts | 188 +++++++ apps/server/src/conversation-plan/runtime.ts | 187 ++++++ .../scripted-harness.test-driver.ts | 200 +++++++ .../scripted-harness.test.ts | 108 ++++ .../conversation-plan/scripted-script.test.ts | 84 +++ .../src/conversation-plan/scripted-script.ts | 79 +++ .../service-correction.test-fixtures.ts | 169 ++++++ .../service-corrections.test.ts | 131 +++++ .../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 | 56 ++ .../conversation-plan/service-queue.test.ts | 242 ++++++++ .../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 | 115 ++++ ...conversation-processor-persistence.test.ts | 35 ++ .../questions/conversation-capacity.test.ts | 314 +++++++++++ .../src/questions/conversation-card-7.test.ts | 205 +++++++ .../planner-lifecycle-durability.test.ts | 242 ++++++++ e2e/harness/scripted-context.ts | 45 ++ e2e/harness/scripted-planner.ts | 218 +++++++ 109 files changed, 15089 insertions(+) create mode 100644 apps/server/src/chat/job-authenticated-memory.test-fixtures.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-heading-fencing.test.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-prompt-card.test-fixtures.ts create mode 100644 apps/server/src/chat/job-prompt-prose.test.ts create mode 100644 apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts create mode 100644 apps/server/src/chat/job-prompt-scripted-harness.test.ts create mode 100644 apps/server/src/chat/job-prompt-suggest.test.ts create mode 100644 apps/server/src/chat/job-scripted-harness.test-fixtures.ts create mode 100644 apps/server/src/chat/job-scripted-harness.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-mirror.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/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/job-scripted-refine-retry.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts create mode 100644 apps/server/src/conversation-plan/option-admission.test.ts create mode 100644 apps/server/src/conversation-plan/pipeline-recommendation-1.test.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/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/prompt-scripted-harness.test.ts create mode 100644 apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts create mode 100644 apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts create mode 100644 apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts create mode 100644 apps/server/src/conversation-plan/research-offer-detection-positive.test.ts create mode 100644 apps/server/src/conversation-plan/research-offer-detection.test-fixtures.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-save.test.ts create mode 100644 apps/server/src/conversation-plan/runtime.ts create mode 100644 apps/server/src/conversation-plan/scripted-harness.test-driver.ts create mode 100644 apps/server/src/conversation-plan/scripted-harness.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-script.test.ts create mode 100644 apps/server/src/conversation-plan/scripted-script.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-processor-persistence.test.ts create mode 100644 apps/server/src/questions/conversation-capacity.test.ts create mode 100644 apps/server/src/questions/conversation-card-7.test.ts create mode 100644 apps/server/src/questions/planner-lifecycle-durability.test.ts create mode 100644 e2e/harness/scripted-context.ts create mode 100644 e2e/harness/scripted-planner.ts 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-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-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-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-prompt-card.test-fixtures.ts b/apps/server/src/chat/job-prompt-card.test-fixtures.ts new file mode 100644 index 00000000..de91c20b --- /dev/null +++ b/apps/server/src/chat/job-prompt-card.test-fixtures.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import type { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { PROMPT_FOR } from "../conversation-plan/job-prompts"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { ConversationPlan } from "@chopin/protocol"; + +type Intent = { kind: "suggest" | "prose"; target: string; trigger: string }; + +export async function promptCard( + opened: Awaited>, + intent: Intent, + script: unknown, +) { + let { kind, target, trigger } = intent; + let plan = opened.plan; + let dir = await mkdtemp(join(tmpdir(), "prompt-card-sdk-")); + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + let identity = await authenticatedMemory(opened, kind); + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if ( + !state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.questions) + ) throw new Error("missing durable questions"); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === target), + revision: state.revision, + }; + } + let observations: Array< + { + toolName: string; + output: unknown; + revision: number; + liveRevision: number | undefined; + saved: Awaited>; + } + > = []; + let driver = createPromptScriptedHarness(dir, { + onResult: async result => { + observations.push({ + toolName: result.toolName, + output: result.output, + revision: plan.revision, + liveRevision: Store.get(plan.questions, target)?.revision, + saved: await saved(), + }); + }, + }); + let agent = createPlannerAgent(driver.fake, kind); + let sessionId: string | undefined; + let setups: Array<{ command: string; directory: string }> = []; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + proseAgent: kind === "prose" ? agent : undefined, + refineAgent: kind === "suggest" ? agent : undefined, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups.length === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected prompt-scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let job: ConversationPlan.Job = { + id: `${kind}:${target}:${trigger}`, + kind, + target, + trigger, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + let prompt = PROMPT_FOR[kind]!(plan, job); + let closing: Promise | undefined; + return { + opened, + plan, + target, + identity, + driver, + results: () => observations, + context: identity.context, + job, + prompt, + saved, + counts: () => ({ + setups: setups.length, + unexpectedCommands, + sandboxDestroys, + credentialReleases, + }), + run: () => Chat.job(identity.context, job, prompt, identity.sessionId), + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await driver.fake.shutdown(); + await plan.chat.running; + await Plan.close(plan); + } finally { + await rm(dir, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-prompt-prose.test.ts b/apps/server/src/chat/job-prompt-prose.test.ts new file mode 100644 index 00000000..d015ecbb --- /dev/null +++ b/apps/server/src/chat/job-prompt-prose.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { proseIntent } from "../conversation-plan/prose-job"; +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import { CARD, OPTION, QUESTION } from "./job-card-memory.test-fixtures"; +import { promptCard } from "./job-prompt-card.test-fixtures"; + +const PARAGRAPH = "We chose GitHub Apps for authentication in the pilot."; + +async function answeredDocument() { + let record = Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + history: [{ choices: [OPTION], owner: "ana", at: 1_758_644_000 }], + }); + let widget = ` + + +\n`; + let opened = await openPlan(`Context.\n\n${widget}`, { questions: [record] }); + await Plan.close(opened.plan); + opened.plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + return opened; +} + +function anchoredParagraph(plan: Plan.Plan) { + let record = plan.records.get(CARD)!; + expect(record.prose).toHaveLength(1); + let anchor = record.prose![0]!; + expect(anchor.orphaned).not.toBe(true); + let matches = Room.digests(plan.document).map((_, index) => index).filter(index => + Room.matchesAnchor(plan.document, anchor, index) + ); + expect(matches).toHaveLength(1); + expect(Room.blockText(plan.document, matches)).toBe(PARAGRAPH); +} + +test("a prompt-only prose job rejects another card then writes its current saved generation durably", async () => { + let opened = await answeredDocument(); + let h: Awaited> | undefined; + try { + let baseline = structuredClone(opened.plan.records.get(CARD)!); + let source = Plan.source(opened.plan); + let revision = opened.plan.revision; + expect(baseline.status).toBe("answered"); + expect(Room.hasQuestionnaire(opened.plan.document, CARD, QUESTION)).toBe(true); + expect(decisionGeneration(baseline)).toBe(2); + let intent = proseIntent(baseline)!; + expect(intent).toEqual({ + kind: "prose", + target: CARD, + trigger: proseJobTrigger(CARD, decisionGeneration(baseline)), + }); + if (intent.kind !== "prose") throw new Error("expected the actual saved prose intent"); + h = await promptCard(opened, { + kind: intent.kind, + target: intent.target, + trigger: intent.trigger, + }, [ + { + tool: "write_decision_prose", + args: { revision: "$revision", id: "not-this-card", text: PARAGRAPH }, + }, + { + tool: "write_decision_prose", + args: { revision: "$revision", id: "$target", text: PARAGRAPH }, + }, + ]); + expect(h.job.trigger).toBe(intent.trigger); + expect(h.prompt).toStartWith("[Background job: prose]"); + expect(h.prompt).toContain(`Decision card id: ${CARD}`); + expect(h.prompt).toContain("Chosen: GitHub Apps"); + expect(await h.run()).toEqual({ + status: "done", + output: JSON.stringify({ title: "Which authentication approach?", mode: "insert" }), + }); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.prose]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "write_decision_prose", + "read_plan", + "write_decision_prose", + ]); + let observations = await h.results(); + expect(observations.map(item => item.toolName)).toEqual([ + "read_plan", + "write_decision_prose", + "read_plan", + "write_decision_prose", + ]); + let [firstRead, rejected, renewedRead, accepted] = observations; + expect(rejected!.output).toBe("Error: This job writes up a different decision."); + expect(rejected!.saved.record).toEqual(baseline); + expect(rejected!.saved.source).toBe(source); + expect(rejected!.revision).toBe(revision); + expect(rejected!.saved.revision).toBe(revision); + let firstRevision = JSON.parse(String(firstRead!.output)).revision; + let renewedRevision = JSON.parse(String(renewedRead!.output)).revision; + expect(firstRevision).toBe(revision); + expect(renewedRevision).toBe(revision); + expect(JSON.parse(String(renewedRead!.output)).source).toBe(source); + expect(h.driver.calls[1]!.input).toEqual({ + revision: firstRevision, + id: "not-this-card", + text: PARAGRAPH, + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision: renewedRevision, + id: CARD, + text: PARAGRAPH, + }); + expect(String(accepted!.output)).toContain('"ok": true'); + let completed = structuredClone(opened.plan.records.get(CARD)!); + expect(accepted!.saved.record).toEqual(completed); + expect(accepted!.saved.source).toBe(Plan.source(opened.plan)); + expect(accepted!.saved.source.split(PARAGRAPH)).toHaveLength(2); + expect(accepted!.saved.revision).toBe(opened.plan.revision); + expect(accepted!.revision).toBeGreaterThan(revision); + expect(completed.definition).toEqual(baseline.definition); + expect(completed.choices).toEqual(baseline.choices); + expect(proseIntent(completed)).toEqual(intent); + anchoredParagraph(opened.plan); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(opened.plan.chat.job).toBeUndefined(); + expect(opened.plan.chat.turn).toBeUndefined(); + expect(opened.plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe( + false, + ); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ).toBe(false); + let completedSource = Plan.source(opened.plan); + await h.close(); + let reopened = await Plan.open(opened.channel.id, opened.backend, opened.server); + try { + expect(reopened.records.get(CARD)).toEqual(completed); + expect(Plan.source(reopened)).toBe(completedSource); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + anchoredParagraph(reopened); + } finally { + await Plan.close(reopened); + } + } finally { + if (h) await h.close(); + else if (!opened.plan.chat.closed) await Plan.close(opened.plan); + } +}); diff --git a/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts b/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts new file mode 100644 index 00000000..29de6c9d --- /dev/null +++ b/apps/server/src/chat/job-prompt-scripted-harness.test-fixtures.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { PROMPT_FOR } from "../conversation-plan/job-prompts"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function promptScripted(kind: "heading" | "refine", script: unknown) { + let opened = await openPlan(kind === "heading" ? "\n" : "Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let target = kind === "heading" ? "document" : await Questions.insertConversationCard( + plan, + opened.server, + opened.channel.id, + { + threadId: "prompt-thread", + header: "Authentication", + question: "What auth should we use?", + options: [], + }, + ); + plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "Choose authentication for a small pilot.", + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + opened.plan = plan; + let dir = await mkdtemp(join(tmpdir(), "prompt-scripted-sdk-")); + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + let identity = await authenticatedMemory(opened, kind); + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if ( + !state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.questions) + ) throw new Error("missing durable questions"); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === target), + revision: state.revision, + }; + } + let observations: Array< + { + toolName: string; + output: unknown; + revision: number; + saved: Awaited>; + } + > = []; + let driver = createPromptScriptedHarness(dir, { + onResult: async result => { + observations.push({ + toolName: result.toolName, + output: result.output, + revision: plan.revision, + saved: await saved(), + }); + }, + }); + let agent = createPlannerAgent(driver.fake, kind); + let sessionId: string | undefined; + let setups: Array<{ command: string; directory: string }> = []; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + headingAgent: kind === "heading" ? agent : undefined, + refineAgent: kind === "refine" ? agent : undefined, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups.length === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected prompt-scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let job: ConversationPlan.Job = { + id: `${kind}:${target}:m1`, + kind, + target, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + let prompt = PROMPT_FOR[kind]!(plan, job); + let closing: Promise | undefined; + return { + opened, + plan, + target, + identity, + driver, + observations, + job, + prompt, + saved, + counts: () => ({ + setups: setups.length, + unexpectedCommands, + sandboxDestroys, + credentialReleases, + }), + run: () => Chat.job(identity.context, job, prompt, identity.sessionId), + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await driver.fake.shutdown(); + await plan.chat.running; + await Plan.close(plan); + } finally { + await rm(dir, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-prompt-scripted-harness.test.ts b/apps/server/src/chat/job-prompt-scripted-harness.test.ts new file mode 100644 index 00000000..e28dce48 --- /dev/null +++ b/apps/server/src/chat/job-prompt-scripted-harness.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { promptScripted } from "./job-prompt-scripted-harness.test-fixtures"; + +function result(output: unknown): { revision: number; source?: string; ok?: boolean } { + return JSON.parse(String(output)); +} + +function cleanup(h: Awaited>) { + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(h.plan.chat.job).toBeUndefined(); + expect(h.plan.chat.turn).toBeUndefined(); + expect(h.plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + h.opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ).toBe(false); +} + +test("a prompt-only heading model reads the real revision before its durable own-tool write", async () => { + let h = await promptScripted("heading", [{ + tool: "draft_heading", + args: { + revision: "$revision", + title: "Auth Strategy", + goal: "Goal: choose authentication for the pilot.", + }, + }]); + try { + expect(h.prompt).toStartWith("[Background job: heading]"); + expect(h.prompt).toContain("Choose authentication for a small pilot."); + expect((await h.run()).status).toBe("done"); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.heading]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan", "draft_heading"]); + expect(h.observations.map(item => item.toolName)).toEqual(["read_plan", "draft_heading"]); + let [read, write] = h.observations; + expect(h.driver.calls[0]!.input).toEqual({}); + expect(h.driver.calls[1]!.input).toMatchObject({ revision: result(read!.output).revision }); + expect(result(read!.output).revision).toBe(read!.revision); + expect(result(read!.output).source).toBe(read!.saved.source); + expect(write!.saved.source).toBe( + "# Auth Strategy\n\nGoal: choose authentication for the pilot.\n", + ); + expect(write!.saved.revision).toBe(h.plan.revision); + expect(write!.revision).toBeGreaterThan(read!.revision); + cleanup(h); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened)).toBe(write!.saved.source); + expect(reopened.chat.entries).toHaveLength(1); + expect(reopened.chat.waiting).toEqual([]); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); + +test("a prompt-only refine model renews read_plan between two actual card writes", async () => { + let title = "Which authentication approach fits the pilot?"; + let rationale = "It keeps the pilot small and concrete."; + let h = await promptScripted("refine", [ + { + tool: "refine_decision", + args: { revision: "$revision", id: "$target", title, add_options: [] }, + }, + { + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + add_options: [{ label: "GitHub Apps", rationale }], + }, + }, + ]); + try { + expect(h.prompt).toStartWith("[Background job: refine]"); + expect(h.prompt).toContain(`Decision card ${h.target}:`); + let initialRevision = h.plan.revision; + expect((await h.run()).status).toBe("done"); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + expect(h.observations.map(item => item.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let [firstRead, firstWrite, secondRead, secondWrite] = h.observations; + let firstRevision = result(firstRead!.output).revision; + let secondRevision = result(secondRead!.output).revision; + expect(firstRevision).toBe(initialRevision); + expect(firstRevision).toBe(firstRead!.revision); + expect(secondRevision).toBe(firstWrite!.revision); + expect(secondRevision).toBe(secondRead!.revision); + expect(secondRevision).toBeGreaterThan(firstRevision); + expect(h.driver.calls[1]!.input).toEqual({ + revision: firstRevision, + id: h.target, + title, + add_options: [], + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision: secondRevision, + id: h.target, + add_options: [{ label: "GitHub Apps", rationale }], + }); + expect(firstWrite!.saved.record!.definition.questions[0]!.question).toBe(title); + expect(firstWrite!.saved.record!.definition.questions[0]!.options).toEqual([]); + expect(result(secondRead!.output).source).toBe(firstWrite!.saved.source); + let record = structuredClone(h.plan.records.get(h.target)!); + let option = record.definition.questions[0]!.options[0]!; + expect(record.definition.questions[0]!.options).toHaveLength(1); + expect(option.label).toBe("GitHub Apps"); + expect(option.id).toBeTruthy(); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale }); + expect(secondWrite!.saved.record).toEqual(record); + expect(secondWrite!.saved.source).toBe(Plan.source(h.plan)); + expect(secondWrite!.saved.revision).toBe(secondWrite!.revision); + expect(secondWrite!.revision).toBeGreaterThan(secondRevision); + cleanup(h); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(reopened.records.get(h.target)).toEqual(record); + expect(Plan.source(reopened)).toBe(secondWrite!.saved.source); + expect(reopened.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/chat/job-prompt-suggest.test.ts b/apps/server/src/chat/job-prompt-suggest.test.ts new file mode 100644 index 00000000..645ca853 --- /dev/null +++ b/apps/server/src/chat/job-prompt-suggest.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { promptCard } from "./job-prompt-card.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; + +test("a prompt-only suggest model refuses a title change before adding its sourced option", async () => { + let opened = await openPlan("Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let h: Awaited> | undefined; + try { + let id = await Questions.insertConversationCard(plan, opened.server, opened.channel.id, { + threadId: "suggest-thread", + header: "Authentication", + question: "What auth should we use?", + options: [], + }); + let source: ConversationPlan.SourceRef = { + messageId: "m-option", + author: { kind: "member", handle: "ana" }, + quote: "GitHub Apps", + start: 0, + end: 11, + role: "option", + }; + plan.chat.entries.push({ + id: source.messageId, + author: source.author, + text: source.quote, + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + opened.plan = plan; + await Plan.persist(plan); + let baseline = structuredClone(plan.records.get(id)!); + let initialSource = Plan.source(plan); + let revision = plan.revision; + let draft = Store.get(plan.questions, id)!; + let draftRevision = draft.revision; + let rationale = "It keeps the pilot small and concrete."; + h = await promptCard(opened, { kind: "suggest", target: id, trigger: source.messageId }, [ + { + tool: "refine_decision", + args: { revision: "$revision", id: "$target", title: "Forbidden title?", add_options: [] }, + }, + { + tool: "refine_decision", + args: { + revision: "$revision", + id: "$target", + add_options: [{ label: source.quote, rationale, source }], + }, + }, + ]); + expect(h.prompt).toStartWith("[Background job: suggest]"); + expect(h.prompt).toContain(`Decision card ${id}:`); + let outcome = await h.run(); + expect(outcome).toEqual({ + status: "done", + output: JSON.stringify({ title: baseline.definition.questions[0]!.question, added: 1 }), + }); + expect(h.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.suggest]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let observations = h.results(); + expect(observations.map(result => result.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "refine_decision", + ]); + let [firstRead, refused, secondRead, added] = observations; + expect(refused!.output).toBe("Error: title and place_after are only changed by a refine job."); + expect(refused!.saved.record).toEqual(baseline); + expect(refused!.saved.source).toBe(initialSource); + expect(refused!.revision).toBe(revision); + expect(refused!.liveRevision).toBe(draftRevision); + expect(refused!.saved.revision).toBe(revision); + expect(JSON.parse(String(firstRead!.output)).revision).toBe(revision); + expect(JSON.parse(String(secondRead!.output)).revision).toBe(revision); + expect(secondRead!.saved.record).toEqual(baseline); + expect(secondRead!.saved.source).toBe(initialSource); + expect(h.driver.calls[1]!.input).toEqual({ + revision, + id, + title: "Forbidden title?", + add_options: [], + }); + expect(h.driver.calls[3]!.input).toEqual({ + revision, + id, + add_options: [{ label: source.quote, rationale, source }], + }); + let record = structuredClone(plan.records.get(id)!); + let question = record.definition.questions[0]!; + expect(question.question).toBe(baseline.definition.questions[0]!.question); + expect(question.options).toHaveLength(1); + let option = question.options[0]!; + expect(option.label).toBe(source.quote); + expect(option.id).toBeTruthy(); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale, source }); + expect(added!.saved.record).toEqual(record); + expect(added!.saved.source).toBe(Plan.source(plan)); + expect(added!.revision).toBeGreaterThan(revision); + expect(added!.saved.revision).toBe(added!.revision); + expect(String(added!.output)).toContain('"ok": true'); + expect(Store.get(plan.questions, id)!.revision).toBe(draftRevision + 1); + expect(h.driver.starts()).toBe(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.identity.driver.starts()).toBe(0); + expect(h.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); + expect(plan.chat.entries).toHaveLength(1); + expect(plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ) + .toBe(false); + let savedSource = Plan.source(plan); + await h.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + expect(plan.records.get(id)).toEqual(record); + expect(Plan.source(plan)).toBe(savedSource); + expect(plan.chat.entries).toHaveLength(1); + expect(plan.chat.waiting).toEqual([]); + expect(h.driver.starts()).toBe(1); + } finally { + await h?.close(); + if (!plan.chat.closed) await Plan.close(plan); + } +}); diff --git a/apps/server/src/chat/job-scripted-harness.test-fixtures.ts b/apps/server/src/chat/job-scripted-harness.test-fixtures.ts new file mode 100644 index 00000000..73a8331d --- /dev/null +++ b/apps/server/src/chat/job-scripted-harness.test-fixtures.ts @@ -0,0 +1,122 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { authenticatedMemory } from "./job-authenticated-memory.test-fixtures"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { createScriptedHarness } from "../conversation-plan/scripted-harness.test-driver"; +import type { ConversationPlan } from "@chopin/protocol"; + +export function headingJob(): ConversationPlan.Job { + return { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +export async function scriptedHeading(script: unknown, held = false) { + let opened = await openPlan("\n"); + let path = await mkdtemp(join(tmpdir(), "current-scripted-harness-")); + await writeFile(join(path, "heading.json"), JSON.stringify(script)); + if (held) await writeFile(join(path, "heading.hold"), "hold"); + let identity = await authenticatedMemory(opened); + let resultCheck: ((output: unknown) => Promise) | undefined; + let driver = createScriptedHarness(path, opened.plan, { + onResult: async result => { + await resultCheck?.(result.output); + }, + }); + let agent = createPlannerAgent(driver.fake, "heading"); + let setupRequests: Array<{ command: string; directory: string }> = []; + let unexpectedSandboxRuns = 0; + let plannerSessionId: string | undefined; + let sandboxDestroys = 0; + let credentialReleases = 0; + identity.context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + headingAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run( + options: { command: string; env?: Record; abortSignal?: AbortSignal }, + ) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(plannerSessionId ?? "") + }`; + if ( + plannerSessionId && setupRequests.length === 0 + && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 + && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setupRequests.push({ command: options.command, directory }); + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedSandboxRuns++; + throw new Error("scripted Harness must not execute unexpected sandbox commands"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: sessionId => { + plannerSessionId = sessionId; + return () => { + credentialReleases++; + }; + }, + }); + let closing: Promise | undefined; + return { + opened, + identity, + driver, + context: identity.context, + onResult(check: (output: unknown) => Promise) { + resultCheck = check; + }, + setupRequests, + plannerSessionId: () => plannerSessionId, + counts: () => ({ + setupRequests: setupRequests.length, + unexpectedSandboxRuns, + sandboxDestroys, + credentialReleases, + }), + async savedSource() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + return (await Plan.readStored(stored)).source; + }, + run(signal?: AbortSignal) { + return Chat.job( + identity.context, + headingJob(), + "Draft the title and goal", + identity.sessionId, + signal, + ); + }, + close() { + return closing ??= (async () => { + identity.revokeAll(); + try { + await Plan.close(opened.plan); + } finally { + await rm(path, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/apps/server/src/chat/job-scripted-harness.test.ts b/apps/server/src/chat/job-scripted-harness.test.ts new file mode 100644 index 00000000..7810d74f --- /dev/null +++ b/apps/server/src/chat/job-scripted-harness.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { HEADING_TOOL_NAMES } from "../harness/tool-names"; +import { scriptedHeading } from "./job-scripted-harness.test-fixtures"; + +let HEADING = { + tool: "draft_heading", + args: { revision: "$revision", title: "Auth Strategy", goal: "Goal: pick auth." }, +}; +let SOURCE = "# Auth Strategy\n\nGoal: pick auth.\n"; + +test("a script runs through current Harness and commits the real heading before its result", async () => { + let h = await scriptedHeading([HEADING]); + let observed = false; + try { + h.onResult(async output => { + expect(String(output)).toContain('"ok": true'); + expect(await h.savedSource()).toBe(SOURCE); + expect(h.opened.plan.chat.job?.kind).toBe("heading"); + observed = true; + }); + expect(await h.run()).toEqual({ + status: "done", + output: JSON.stringify({ title: "Auth Strategy", goal: "Goal: pick auth." }), + }); + expect(observed).toBe(true); + expect(h.plannerSessionId()).toBeDefined(); + expect(h.setupRequests).toEqual([{ + command: 'mkdir -p "$WORK_DIR"', + directory: `/tmp/${encodeURIComponent(h.driver.fake.harnessId)}-${ + encodeURIComponent(h.plannerSessionId()!) + }`, + }]); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + expect(h.driver.results).toHaveLength(1); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + await h.close(); + let reopened = await Plan.open(h.opened.channel.id, h.opened.backend, h.opened.server); + try { + expect(Plan.source(reopened)).toBe(SOURCE); + expect(reopened.chat.entries).toEqual([]); + expect(reopened.chat.waiting).toEqual([]); + } finally { + await Plan.close(reopened); + } + } finally { + await h.close(); + } +}); + +test("an inactive scripted write aborts the current stream and prevents a heading mutation", async () => { + let h = await scriptedHeading([ + { + tool: "edit_plan", + args: { revision: "$revision", operations: [{ op: "insert_root", source: "x\n" }] }, + }, + HEADING, + ]); + try { + expect(await h.run()).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: edit_plan", + }); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls[0]?.toolName).toBe("edit_plan"); + expect(h.driver.results).toMatchObject([{ + toolName: "edit_plan", + output: { type: "execution-denied" }, + }]); + expect(h.driver.results.some(result => result.toolName === "draft_heading")).toBe(false); + expect(await h.savedSource()).toBe(""); + expect(Plan.source(h.opened.plan)).toBe(""); + expect(h.opened.broadcasts.some(frame => frame.kind === "plan:update")).toBe(false); + expect(h.opened.plan.chat.entries).toEqual([]); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + } finally { + await h.close(); + } +}); + +test("aborting a held current Harness script drains its session without a document write", async () => { + let h = await scriptedHeading([HEADING], true); + let controller = new AbortController(); + try { + let running = h.run(controller.signal); + await h.driver.entered; + expect(h.opened.plan.chat.job?.id).toBe("heading:document:m0"); + expect(h.opened.plan.chat.turn).toBeDefined(); + expect(h.driver.calls).toEqual([]); + controller.abort(); + let outcome = await running; + expect(outcome.status).toBe("failed"); + expect(h.driver.tools).toEqual([[...HEADING_TOOL_NAMES]]); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(await h.savedSource()).toBe(""); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + expect(h.opened.plan.chat.busy).toBe(false); + expect(h.identity.driver.starts()).toBe(0); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + } finally { + controller.abort(); + await h.close(); + } +}); + +test("abort waits for a held result observer before draining the current Harness session", async () => { + let h = await scriptedHeading([HEADING, HEADING]); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let controller = new AbortController(); + let settled = false; + let observerFinished = false; + try { + h.onResult(async () => { + expect(await h.savedSource()).toBe(SOURCE); + entered.resolve(); + await release.promise; + observerFinished = true; + }); + let running = h.run(controller.signal); + void running.then(() => { + settled = true; + }); + await entered.promise; + controller.abort(); + await Bun.sleep(10); + expect(settled).toBe(false); + expect(observerFinished).toBe(false); + expect(h.driver.destroyed()).toBe(0); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + release.resolve(); + expect((await running).status).toBe("failed"); + expect(settled).toBe(true); + expect(observerFinished).toBe(true); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["draft_heading"]); + expect(h.driver.results).toHaveLength(1); + expect(h.driver.destroyed()).toBe(1); + expect(h.counts()).toEqual({ + setupRequests: 1, + unexpectedSandboxRuns: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(await h.savedSource()).toBe(SOURCE); + expect(h.opened.plan.chat.job).toBeUndefined(); + expect(h.opened.plan.chat.turn).toBeUndefined(); + } finally { + controller.abort(); + release.resolve(); + await h.close(); + } +}); diff --git a/apps/server/src/config-conversation-bounds.test.ts b/apps/server/src/config-conversation-bounds.test.ts new file mode 100644 index 00000000..bf92cf47 --- /dev/null +++ b/apps/server/src/config-conversation-bounds.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { describe as description } from "./config"; +import { configured } from "./config-conversation.test-fixtures"; + +test("only the exact opt-in enables analysis and a generic transport key is insufficient", () => { + for (let flag of [undefined, "", "off", "ON", "true", "1"]) { + expect(configured({ CONVERSATION_PLAN: flag })).toMatchObject({ + conversationPlan: false, + conversationPlanModel: "jev-latest", + conversationPlanTimeoutMs: 30_000, + }); + } + expect(() => configured({ CONVERSATION_PLAN: "on", TYPESAFE_API_KEY: "synthetic" })) + .toThrow("JEV_API_KEY is required when CONVERSATION_PLAN=on"); + expect(() => configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "" })).toThrow("JEV_API_KEY"); +}); + +test("timeout bounds are validated even when conversation analysis is off", () => { + for (let timeout of ["100", "60000", "000100"]) { + expect(configured({ JEV_TIMEOUT_MS: timeout }).conversationPlanTimeoutMs).toBe(Number(timeout)); + } + for (let timeout of ["99", "60001", "-100", "+100", " 100", "100 ", "1e3", "9007199254740992"]) { + expect(() => configured({ JEV_TIMEOUT_MS: timeout })).toThrow("JEV_TIMEOUT_MS"); + } +}); + +test("model alias validation is opt-in and does not expose the transport credential", () => { + let config = configured({ + CONVERSATION_PLAN: "on", + JEV_API_KEY: "synthetic-test-key", + JEV_MODEL: "jev.experimental-v1_2", + JEV_TIMEOUT_MS: "100", + }); + expect(config.conversationPlanModel).toBe("jev.experimental-v1_2"); + expect(description(config)).toContain("conversation plan: jev.experimental-v1_2"); + expect(description(config)).not.toContain("synthetic-test-key"); + expect(configured({ JEV_MODEL: "invalid alias" }).conversationPlanModel).toBe("invalid alias"); + for (let alias of ["with spaces", "with/slash", "x".repeat(101)]) { + expect(() => + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "synthetic", JEV_MODEL: alias }) + ) + .toThrow("JEV_MODEL"); + } +}); diff --git a/apps/server/src/config-conversation.test-fixtures.ts b/apps/server/src/config-conversation.test-fixtures.ts new file mode 100644 index 00000000..c4ceabe2 --- /dev/null +++ b/apps/server/src/config-conversation.test-fixtures.ts @@ -0,0 +1,46 @@ +import { load } from "./config"; + +const REQUIRED = { + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", + APP_ORIGIN: "https://chopin.example", + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client-id", + GITHUB_APP_CLIENT_SECRET: "client-secret", + SESSION_ENCRYPTION_KEY: "11".repeat(32), +}; + +export function configured(overrides: Record = {}) { + let env: Record = { + ...REQUIRED, + HARNESS: undefined, + HARNESS_AUTH: undefined, + AUTH_MODE: undefined, + SERVER_HOST: undefined, + PORT: undefined, + CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, + AGENT: undefined, + MODEL: undefined, + BACKGROUND_JOBS: undefined, + WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + JEV_API_KEY: undefined, + TYPESAFE_API_KEY: undefined, + GITHUB_ALLOWED_USERS: undefined, + GITHUB_ALLOWED_ORGANIZATIONS: undefined, + ...overrides, + }; + let previous = { ...process.env }; + for (let [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return load(); + } finally { + for (let key of Object.keys(env)) delete process.env[key]; + Object.assign(process.env, previous); + } +} diff --git a/apps/server/src/config-conversation.test.ts b/apps/server/src/config-conversation.test.ts new file mode 100644 index 00000000..d19cef51 --- /dev/null +++ b/apps/server/src/config-conversation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { configured } from "./config-conversation.test-fixtures"; + +describe("conversation configuration", () => { + it("enables conversation analysis only by explicit opt-in, independently of Planner", () => { + expect(configured({ JEV_API_KEY: "present" }).conversationPlan).toBe(false); + expect(configured({ CONVERSATION_PLAN: "on", AGENT: "off", JEV_API_KEY: "present" })) + .toMatchObject({ agent: false, conversationPlan: true, conversationPlanModel: "jev-latest" }); + expect(() => configured({ CONVERSATION_PLAN: "on" })).toThrow("JEV_API_KEY"); + expect(() => + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present", JEV_MODEL: "bad name" }) + ) + .toThrow("JEV_MODEL"); + expect( + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present" }) + .conversationPlanTimeoutMs, + ).toBe(30_000); + expect( + configured({ CONVERSATION_PLAN: "on", JEV_API_KEY: "present", JEV_TIMEOUT_MS: "45000" }) + .conversationPlanTimeoutMs, + ).toBe(45_000); + for (let timeout of ["0", "99", "60001", "30s", "", "1.5"]) { + expect(() => + configured({ + CONVERSATION_PLAN: "on", + JEV_API_KEY: "present", + JEV_TIMEOUT_MS: timeout, + }) + ).toThrow("JEV_TIMEOUT_MS"); + } + }); +}); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 396c1634..ade6b346 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -18,6 +18,11 @@ function configured(overrides: Record = {}) { AGENT: undefined, BACKGROUND_JOBS: undefined, WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_API_KEY: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + TYPESAFE_API_KEY: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, AUTH_MODE: undefined, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d4b3da51..b082dd4c 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,10 @@ export type Config = { agent: boolean; backgroundJobs: boolean; webResearch: boolean; + /** Explicitly enables the conversation-derived cards prototype. */ + conversationPlan?: boolean; + conversationPlanModel?: string; + conversationPlanTimeoutMs?: number; /** * Origin of a running Vite, when developing. * @@ -97,6 +101,23 @@ function storage(): StorageConfig { export function load(): Config { let agent = process.env.AGENT !== "off"; let backgroundJobs = process.env.BACKGROUND_JOBS !== "off"; + let conversationPlan = process.env.CONVERSATION_PLAN === "on"; + let conversationPlanModel = process.env.JEV_MODEL || "jev-latest"; + let timeoutRaw = process.env.JEV_TIMEOUT_MS; + let conversationPlanTimeoutMs = timeoutRaw === undefined ? 30_000 : Number(timeoutRaw); + if ( + (timeoutRaw !== undefined && !/^\d+$/.test(timeoutRaw)) + || !Number.isSafeInteger(conversationPlanTimeoutMs) + || conversationPlanTimeoutMs < 100 || conversationPlanTimeoutMs > 60_000 + ) { + throw new Error("JEV_TIMEOUT_MS must be an integer between 100 and 60000 milliseconds"); + } + if (conversationPlan && !process.env.JEV_API_KEY) { + throw new Error("JEV_API_KEY is required when CONVERSATION_PLAN=on"); + } + if (conversationPlan && !/^[A-Za-z0-9._-]{1,100}$/.test(conversationPlanModel)) { + throw new Error("JEV_MODEL must be a valid model alias"); + } let selection = harnessSelection(); let serverPort = port(); return { @@ -106,6 +127,9 @@ export function load(): Config { agent, backgroundJobs, webResearch: agent && backgroundJobs && process.env.WEB_RESEARCH !== "off", + conversationPlan, + conversationPlanModel, + conversationPlanTimeoutMs, devClient: process.env.DEV_CLIENT || undefined, storage: storage(), auth: loadAuth(serverPort, selection.host), @@ -135,6 +159,9 @@ export function describe(config: Config): string { `harness: ${config.harness}`, config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", + config.conversationPlan + ? `conversation plan: ${config.conversationPlanModel}` + : "conversation plan: off", admission, `storage: ${config.storage.driver}`, ]; diff --git a/apps/server/src/conversation-plan/card-effect-prompts.ts b/apps/server/src/conversation-plan/card-effect-prompts.ts new file mode 100644 index 00000000..81ac753b --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-prompts.ts @@ -0,0 +1,107 @@ +import * as Chat from "../chat/service"; + +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands } from "./service"; + +import { activeSettleDeferral, effectivePreference } from "./preference"; + +import { promptText, sameSource, shouldPrompt } from "./card-prompts"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardPrompts( + plan: Plan, + server: Server, + roomId: string, + announcer: Chat.Announcer, +): Pick { + return { + prompt: async (id, generation, source) => { + await Service.exclusive(plan, async () => { + let record = plan.records.get(id); + if (!record) throw new Error("prompt card is missing"); + let thread = plan.conversationPlan.threads.find(item => item.id === record.threadId); + if (thread && activeSettleDeferral(thread, plan.conversationPlan.events)) return; + if (thread?.status === "reopened" && !thread.pendingSettle) return; + if (source && thread?.pendingSettle) { + let events = plan.conversationPlan.events; + let deferredAt = events.findLastIndex(item => + item.type === "settle.deferred" && item.threadId === thread.id + ); + if (deferredAt >= 0) { + let triggerAt = events.findLastIndex(item => + item.threadId === thread.id && "source" in item + && item.source?.messageId === source.messageId + && (item.type === "settle.suggested" || item.type === "settle.agreed" + || item.type === "settle.resumed" || item.type === "stance.changed") + ); + if (triggerAt <= deferredAt) return; + } + let current = effectivePreference(thread, plan.conversationPlan.events); + if (source.sourceMessageIds !== undefined) { + if (!sameSource(current, source.optionId, source.sourceMessageIds)) { + return; + } + } else if ( + current?.optionId !== source.optionId + || !current?.messageIds.includes(source.messageId) + ) return; + } + let title = record.definition.questions[0]?.question; + if (!title) throw new Error("prompt card title is missing"); + let identity = source?.sourceMessageIds === undefined ? undefined : { + optionId: source.optionId, + messageIds: source.sourceMessageIds, + }; + if (!shouldPrompt(plan.chat.entries, id, record, generation, identity)) return; + await Chat.noticeExclusive(announcer, { + text: promptText(title), + decision: { + questionnaireId: id, + kind: "prompt", + generation, + ...(identity + ? { + sourceMessageIds: [...identity.messageIds], + ...(identity.optionId ? { suggestedOptionId: identity.optionId } : {}), + } + : {}), + }, + }); + }); + }, + deferPrompt: async effect => { + await Service.exclusive(plan, async () => { + let thread = plan.conversationPlan.threads.find(item => item.id === effect.threadId); + let deferred = thread && activeSettleDeferral(thread, plan.conversationPlan.events); + if ( + !thread || !deferred || deferred.id !== effect.deferredEventId + || deferred.proposalId !== effect.proposalId || !thread.questionnaireId + ) return; + let id = thread.questionnaireId; + let record = plan.records.get(id); + if (!record || !Questions.isOpenStatus(record.status)) return; + let title = record.definition.questions[0]?.question; + if (!title) throw new Error("deferred card title is missing"); + let cleared = await Questions.suggest(plan, server, roomId, id, undefined, true); + if (!cleared) throw new Error("deferred card is temporarily unavailable"); + for (let entry of plan.chat.entries) { + if ( + entry.decision?.kind !== "prompt" || entry.decision.questionnaireId !== id + || entry.decision.generation !== record.history.length + ) continue; + await Chat.refreshNoticeExclusive(announcer, entry.id, { + text: `Decision paused pending verification: ${title}`, + decision: { questionnaireId: id, kind: "activity" }, + }); + } + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-scoped.ts b/apps/server/src/conversation-plan/card-effect-scoped.ts new file mode 100644 index 00000000..ef74daf7 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-scoped.ts @@ -0,0 +1,116 @@ +import * as Chat from "../chat/service"; +import { isDeepStrictEqual } from "node:util"; + +import * as Service from "../plan/service"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; + +import type { EffectCommands } from "./service"; + +import { activeScopedSupport, currentScopedProposal, targetsScopedProposal } from "./events"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createScopedNotice( + plan: Plan, + announcer: Chat.Announcer, +): Pick { + return { + scopedChoice: async effect => { + await Service.exclusive(plan, async () => { + let state = plan.conversationPlan; + let thread = state.threads.find(item => item.id === effect.threadId); + let record = plan.records.get(effect.cardId); + if (!record) throw new Error("scoped choice card is missing"); + let trigger = state.events.find(item => item.id === effect.triggerEventId); + let proposal = trigger?.type === "stance.changed" + ? state.events.find((item): item is Extract => item.type === "scoped-choice.proposed" && item.id === effect.proposalId) + : thread && currentScopedProposal(thread, state.events); + let sources = proposal + ? [ + proposal.source, + ...(trigger?.type === "scoped-choice.agreed" || trigger?.type === "stance.changed" + ? [trigger.source] + : []), + ] + : []; + if ( + !thread || !proposal || proposal.id !== effect.proposalId + || thread.questionnaireId !== effect.cardId + || ["decided", "discarded"].includes(thread.status) + || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + || record.history.length !== effect.generation + || record.definition.questions[0]?.options.filter(item => + item.id === effect.optionId && item.label === effect.label + ).length !== 1 + || proposal.cardId !== effect.cardId || proposal.optionId !== effect.optionId + || proposal.label !== effect.label || proposal.scope !== effect.scope + || thread.pendingScopedChoice + && thread.pendingScopedChoice.proposalId !== proposal.id + || state.events.some(item => + item.type === "scoped-choice.saved" && item.proposalId === proposal.id + ) + || trigger?.type !== "scoped-choice.proposed" + && trigger?.type !== "scoped-choice.agreed" + && trigger?.type !== "stance.changed" + || trigger.type === "scoped-choice.proposed" && trigger.id !== proposal.id + || trigger.type === "scoped-choice.agreed" && trigger.proposalId !== proposal.id + || trigger.type === "stance.changed" && ( + trigger.threadId !== thread.id + || !targetsScopedProposal(trigger, proposal.id, proposal.optionId) + || trigger.source.author.kind !== "member" + ) + || trigger.type !== "stance.changed" && !thread.pendingScopedChoice + || !isDeepStrictEqual(sources, effect.sources) + ) return; + let active = activeScopedSupport(state.events, proposal); + let decision = { + questionnaireId: effect.cardId, + kind: "scoped-choice" as const, + threadId: effect.threadId, + proposalId: effect.proposalId, + cardId: effect.cardId, + optionId: effect.optionId, + label: effect.label, + scope: effect.scope, + generation: effect.generation, + triggerEventId: active.at(-1)?.id ?? proposal.id, + sources: active.map(item => structuredClone(item.source)), + }; + let text = `Ready to save ${effect.label} for this spike.`; + let existing = plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + && entry.decision.threadId === effect.threadId + && entry.decision.questionnaireId === effect.cardId + && entry.decision.cardId === effect.cardId + && entry.decision.optionId === effect.optionId + && entry.decision.scope === effect.scope + && entry.decision.generation === effect.generation + ); + if (!thread.pendingScopedChoice || active.length === 0) { + if (existing.length > 1) throw new Error("scoped choice notice identity conflicts"); + if (existing[0]) { + await Chat.refreshNoticeExclusive(announcer, existing[0].id, { + text: `Scoped Save for ${effect.label} is no longer available.`, + decision: { questionnaireId: effect.cardId, kind: "activity" }, + }); + } + return; + } + if (existing.length > 0) { + if ( + existing.length !== 1 || existing[0].author.kind !== "system" + || existing[0].text !== text + ) throw new Error("scoped choice notice identity conflicts"); + if (isDeepStrictEqual(existing[0].decision, decision)) return; + await Chat.refreshNoticeExclusive(announcer, existing[0].id, { text, decision }); + return; + } + await Chat.noticeExclusive(announcer, { text, decision }); + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-suggestion.ts b/apps/server/src/conversation-plan/card-effect-suggestion.ts new file mode 100644 index 00000000..9e7b6285 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-suggestion.ts @@ -0,0 +1,50 @@ +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands } from "./service"; + +import { activeSettleDeferral, effectivePreference } from "./preference"; + +import { sameSource } from "./card-prompts"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardSuggestion( + plan: Plan, + server: Server, + roomId: string, +): Pick { + return { + suggest: async (id, input) => { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + if (!record) throw new Error("card is missing"); + let thread = plan.conversationPlan.threads.find(item => item.id === record.threadId); + if (thread && activeSettleDeferral(thread, plan.conversationPlan.events)) return; + if (thread?.status === "reopened" && !thread.pendingSettle) return; + if (thread?.pendingSettle) { + let current = effectivePreference(thread, plan.conversationPlan.events); + if (!sameSource(current, input.optionId, input.messageIds)) return; + } + let applied = await Questions.suggest( + plan, + server, + roomId, + id, + input.optionId ? { optionId: input.optionId, messageIds: input.messageIds } : undefined, + true, + ); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (!applied && Questions.isOpenStatus(plan.records.get(id)?.status ?? "cancelled")) { + let live = Store.get(plan.questions, id); + if (!live || live.claim) throw new Error("card is temporarily unavailable"); + } + }); + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-effect-targets.ts b/apps/server/src/conversation-plan/card-effect-targets.ts new file mode 100644 index 00000000..182c8884 --- /dev/null +++ b/apps/server/src/conversation-plan/card-effect-targets.ts @@ -0,0 +1,91 @@ +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import type { Server } from "bun"; + +import type { Plan } from "../plan/service"; + +import type { SocketData } from "../wire"; +import type { EffectCommands, Processor } from "./service"; +import { proseIntent } from "./prose-job"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function createCardTargets( + plan: Plan, + server: Server, + roomId: string, + processor: Processor, +): Pick { + return { + proseReady: (threadId, id, trigger) => { + let thread = plan.conversationPlan.threads.find(item => item.id === threadId); + let record = plan.records.get(id); + return !!record && thread?.questionnaireId === id && record.threadId === threadId + && proseIntent(record)?.trigger === trigger; + }, + target: threadId => { + let id = plan.conversationPlan.threads.find(item => item.id === threadId)?.questionnaireId; + if (!id) return { kind: "unlinked" }; + let record = plan.records.get(id); + if (!record) return { kind: "unlinked" }; + return record.status === "open" || record.status === "reopened" + ? { kind: "open", id } + : { kind: "closed" }; + }, + insertCard: async input => { + let linked = plan.conversationPlan.threads.find(item => item.id === input.threadId) + ?.questionnaireId; + if (linked && !plan.records.has(linked)) throw new Error("linked card is missing"); + return await Questions.insertConversationCard(plan, server, roomId, input); + }, + link: async (threadId, questionnaireId) => { + await processor.record(state => { + let thread = state.threads.find(item => item.id === threadId); + if (!thread) throw new Error("card link thread is missing"); + if (thread.questionnaireId === questionnaireId) return; + if (thread.questionnaireId) throw new Error("thread already has a different card"); + return { + id: `card:${questionnaireId}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: Math.floor(Date.now() / 1_000), + questionnaireId, + }; + }); + }, + addOption: async (id, input) => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + if (!record) throw new Error("card is missing"); + let existing = record.definition.questions[0]?.options.find(option => + option.id === input.optionId + ); + if (existing) { + // A later title refinement may have changed this option's label. + if (record.optionOrigins[input.optionId]?.origin === "chat") return; + console.error("[conversation-plan] option ID belongs to another origin:", input.optionId); + return; + } + let result = await Questions.addServerOption(plan, server, roomId, id, { + ...input, + origin: "chat", + }); + if (!result.ok) { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + // A card still closed after Save or Discard supersedes the undelivered addition. + if (!Questions.isOpenStatus(plan.records.get(id)?.status ?? "cancelled")) return; + if ( + plan.conversationPlan.events.some(event => + event.id === input.trigger && event.origin === "human" + && event.type === "option.added" + ) + ) throw new Error(`Could not add the accepted excerpt option: ${result.reason}`); + if (result.reason === "closed") throw new Error("card is temporarily unavailable"); + console.error("[conversation-plan] could not represent a quoted option:", result); + } + }, + }; +} diff --git a/apps/server/src/conversation-plan/card-mirror-runner.ts b/apps/server/src/conversation-plan/card-mirror-runner.ts new file mode 100644 index 00000000..0473442d --- /dev/null +++ b/apps/server/src/conversation-plan/card-mirror-runner.ts @@ -0,0 +1,43 @@ +import type { Plan } from "../plan/service"; + +import type { Processor } from "./service"; + +import { mirroredEvent } from "./card-mirror"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +let drains = new WeakMap>(); + +export async function mirrorCard(plan: Plan, processor: Processor): Promise { + let existing = drains.get(plan); + if (existing) return existing; + let succeeded = false; + let operation = (async () => { + while (plan.pendingCardActions.length > 0) { + let action = plan.pendingCardActions[0]!; + await processor.record(state => { + let thread = state.threads.find(item => item.id === action.threadId); + if (!thread) throw new Error("card mirror thread is missing"); + return mirroredEvent(thread, action); + }, action.id); + } + succeeded = true; + })(); + drains.set(plan, operation); + try { + await operation; + } finally { + if (drains.get(plan) === operation) drains.delete(plan); + if (succeeded && plan.pendingCardActions.length > 0) { + queueMicrotask(() => { + void mirrorCard(plan, processor).catch(error => + console.error("[conversation-plan] could not mirror a card action:", error) + ); + }); + } + } +} + +export async function wakeCardMirror(plan: Plan, processor: Processor): Promise { + processor.wake(); + await mirrorCard(plan, processor); +} diff --git a/apps/server/src/conversation-plan/card-mirror.ts b/apps/server/src/conversation-plan/card-mirror.ts new file mode 100644 index 00000000..f7279d5e --- /dev/null +++ b/apps/server/src/conversation-plan/card-mirror.ts @@ -0,0 +1,62 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { PendingCardAction } from "../questions/card-actions"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, cards.ts pure event derivation. +export function mirroredEvent( + thread: ConversationPlan.Thread, + action: PendingCardAction, +): ConversationPlan.Event | undefined { + let base = { + id: action.id, + threadId: action.threadId, + observedThreadVersion: thread.version, + at: action.at, + }; + switch (action.kind) { + case "decided": { + let chosen = action.optionIds.find(id => + thread.contributions.some(item => item.kind === "option" && item.id === id) + ); + return { + ...base, + type: "decision.recorded", + origin: "human", + actor: { kind: "member", handle: action.actor }, + text: action.text, + ...(chosen ? { optionId: chosen } : {}), + explicit: true, + }; + } + case "reopened": + return { + ...base, + type: "decision.reopened", + origin: "human", + actor: { kind: "member", handle: action.actor }, + explicit: true, + }; + case "discarded": + return { + ...base, + type: "thread.discarded", + origin: "human", + actor: { kind: "member", handle: action.actor }, + }; + case "option-added": + if (thread.contributions.some(item => item.id === action.optionId)) return; + return { + ...base, + type: "option.added", + origin: action.origin, + actor: action.origin === "planner" + ? { kind: "agent" } + : { kind: "member", handle: action.actor }, + contribution: { + id: action.optionId, + text: action.label, + authoring: action.origin === "planner" ? "scribe" : "human-edited", + targetId: thread.id, + }, + }; + } +} diff --git a/apps/server/src/conversation-plan/card-prompts.ts b/apps/server/src/conversation-plan/card-prompts.ts new file mode 100644 index 00000000..5bc39d8b --- /dev/null +++ b/apps/server/src/conversation-plan/card-prompts.ts @@ -0,0 +1,42 @@ +import type { Chat as WireChat } from "@chopin/protocol"; + +import { effectivePreference } from "./preference"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations/property nodes; synchronous wrappers only. +export function promptText(title: string): string { + return `Ready to decide: ${title}`; +} + +export function sameSource( + current: ReturnType, + optionId: string | undefined, + messageIds: readonly string[], +): boolean { + let currentIds = current?.messageIds ?? []; + return current?.optionId === optionId && currentIds.length === messageIds.length + && currentIds.every((id, index) => id === messageIds[index]); +} + +export function shouldPrompt( + entries: readonly WireChat.Entry[], + questionnaireId: string, + record: { status: string; history: readonly unknown[] }, + generation: number, + source?: { optionId?: string; messageIds: readonly string[] }, +): boolean { + if (record.status !== "open" && record.status !== "reopened") return false; + if (record.history.length !== generation) return false; + for (let index = entries.length - 1; index >= 0; index--) { + let decision = entries[index]?.decision; + if ( + decision?.kind !== "prompt" || decision.questionnaireId !== questionnaireId + || decision.generation !== generation + ) continue; + if (!source) return false; + return decision.sourceMessageIds === undefined + || decision.suggestedOptionId !== source.optionId + || decision.sourceMessageIds.length !== source.messageIds.length + || decision.sourceMessageIds.some((id, index) => id !== source.messageIds[index]); + } + return true; +} diff --git a/apps/server/src/conversation-plan/cards-1.test.ts b/apps/server/src/conversation-plan/cards-1.test.ts new file mode 100644 index 00000000..0b32ef63 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-1.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import { mirroredEvent, promptText, shouldPrompt } from "./cards"; + +import type { PendingCardAction } from "../questions/card-actions"; + +import { base, CARD, OPTION, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a decided card mirrors its captured actor, choice, text and stable generation", () => { + let action: PendingCardAction = { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }; + expect(mirroredEvent(thread(), action)).toMatchObject({ + id: action.id, + type: "decision.recorded", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 9, + optionId: OPTION, + text: "GitHub Apps", + explicit: true, + }); +}); + +test("reopen, discard and human or Planner options retain their captured identity", () => { + let reopen: PendingCardAction = { + ...base(), + id: `card:${CARD}:reopened:1`, + kind: "reopened", + generation: 1, + }; + expect(mirroredEvent(thread("decided"), reopen)).toMatchObject({ + id: reopen.id, + type: "decision.reopened", + actor: { kind: "member", handle: "ana" }, + }); + let discard: PendingCardAction = { + ...base(), + id: `card:${CARD}:discarded`, + kind: "discarded", + }; + expect(mirroredEvent(thread(), discard)).toMatchObject({ + id: discard.id, + type: "thread.discarded", + }); + let option: PendingCardAction = { + ...base(), + id: `card:${CARD}:option:01K0N4W3B7P27CBAEC7A8C8WEC`, + kind: "option-added", + origin: "human", + optionId: "01K0N4W3B7P27CBAEC7A8C8WEC", + label: "Own service", + }; + expect(mirroredEvent(thread(), option)).toMatchObject({ + id: option.id, + type: "option.added", + origin: "human", + contribution: { id: option.optionId, text: "Own service", authoring: "human-edited" }, + }); + let planner = { ...option, origin: "planner" as const }; + expect(mirroredEvent(thread(), planner)).toMatchObject({ + type: "option.added", + origin: "planner", + actor: { kind: "agent" }, + contribution: { authoring: "scribe" }, + }); + expect(mirroredEvent(thread(), { ...option, optionId: OPTION })).toBeUndefined(); +}); + +test("prompt generations distinguish same-second Save and Reopen", () => { + let entries = [{ + id: "p0", + author: { kind: "system" as const }, + text: "Ready to decide: Which auth system?", + ts: 9, + decision: { questionnaireId: CARD, kind: "prompt" as const, generation: 0 }, + }]; + let open = { status: "open", history: [] }; + let reopened = { status: "reopened", history: [{ at: 9 }] }; + let answered = { status: "answered", history: [] }; + let discarded = { status: "discarded", history: [] }; + let later = { ...entries[0], id: "p1", decision: { ...entries[0].decision, generation: 1 } }; + let other = { + ...entries[0], + id: "other", + decision: { ...entries[0].decision, questionnaireId: ulid() }, + }; + expect(promptText("Which auth system?")).toBe("Ready to decide: Which auth system?"); + expect(shouldPrompt([], CARD, open, 0)).toBe(true); + expect(shouldPrompt(entries, CARD, open, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, reopened, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, reopened, 1)).toBe(true); + expect(shouldPrompt([...entries, later], CARD, reopened, 1)).toBe(false); + expect(shouldPrompt([other], CARD, open, 0)).toBe(true); + expect(shouldPrompt([], CARD, answered, 0)).toBe(false); + expect(shouldPrompt([], CARD, discarded, 0)).toBe(false); + expect(shouldPrompt(entries, CARD, open, 0, { optionId: OPTION, messageIds: ["m5"] })) + .toBe(true); + let sourced = [{ + ...entries[0], + decision: { ...entries[0].decision, suggestedOptionId: OPTION, sourceMessageIds: ["m5"] }, + }]; + expect(shouldPrompt(sourced, CARD, open, 0, { optionId: OPTION, messageIds: ["m5"] })) + .toBe(false); + expect(shouldPrompt(sourced, CARD, open, 0, { optionId: OPTION, messageIds: ["m6"] })) + .toBe(true); +}); + +test("source-aware prompt retries compare the latest notice, including recurring clears", () => { + let open = { status: "open", history: [] }; + let notice = (id: string, sourceMessageIds: string[], suggestedOptionId?: string) => ({ + id, + author: { kind: "system" as const }, + text: "Ready to decide: Which auth system?", + ts: 9, + decision: { + questionnaireId: CARD, + kind: "prompt" as const, + generation: 0, + sourceMessageIds, + ...(suggestedOptionId ? { suggestedOptionId } : {}), + }, + }); + let bram = { optionId: OPTION, messageIds: ["m5"] }; + let clear = { messageIds: [] }; + let both = notice("both", ["m5", "m6"], OPTION); + let first = notice("first", ["m5"], OPTION); + let firstClear = notice("first-clear", []); + expect(shouldPrompt([first, both], CARD, open, 0, bram)).toBe(true); + expect(shouldPrompt([first, both, notice("returned", ["m5"], OPTION)], CARD, open, 0, bram)) + .toBe(false); + expect(shouldPrompt([first, firstClear, notice("new", ["m9"], OPTION)], CARD, open, 0, clear)) + .toBe(true); + expect(shouldPrompt([first, firstClear], CARD, open, 0, clear)).toBe(false); +}); diff --git a/apps/server/src/conversation-plan/cards-2.test.ts b/apps/server/src/conversation-plan/cards-2.test.ts new file mode 100644 index 00000000..997a9a15 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-2.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; + +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { cardSource, OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("source withdrawal and recurring clear supersede durable Chat notices", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let proposal: ConversationPlan.Event = { + id: "proposal", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: cardSource("m5", "Bram", "resolution"), + optionId: OPTION, + }; + let agreement: ConversationPlan.Event = { + ...proposal, + id: "agreement", + type: "settle.agreed", + source: cardSource("m6", "Ada", "support"), + }; + let adaWithdrawal: ConversationPlan.Event = { + ...proposal, + id: "ada-withdrawal", + type: "stance.changed", + scopedProposalId: null, + source: cardSource("m7", "Ada", "objection"), + position: "oppose", + }; + let bramWithdrawal: ConversationPlan.Event = { + ...adaWithdrawal, + id: "bram-withdrawal", + source: cardSource("m8", "Bram", "objection"), + }; + let newProposal: ConversationPlan.Event = { + ...proposal, + id: "new-proposal", + source: cardSource("m9", "Bram", "resolution"), + }; + let secondWithdrawal: ConversationPlan.Event = { + ...bramWithdrawal, + id: "second-withdrawal", + source: cardSource("m10", "Bram", "objection"), + }; + context.plan.conversationPlan.threads = [{ + ...thread(), + questionnaireId: id, + pendingSettle: { optionId: OPTION, proposer: "Bram", messageId: "m5" }, + }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let records = new Map([[id, { history: [] }]]); + for ( + let [event, expectedSources] of [ + [proposal, ["m5"]], + [agreement, ["m5", "m6"]], + [adaWithdrawal, ["m5"]], + [bramWithdrawal, []], + [newProposal, ["m9"]], + [secondWithdrawal, []], + ] as const + ) { + context.plan.conversationPlan.events.push(event); + if (event === newProposal) { + context.plan.conversationPlan.threads[0]!.pendingSettle = { + optionId: OPTION, + proposer: "Bram", + messageId: "m9", + }; + } + expect( + await runEffects( + deps, + effectsFor([event], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds ?? []) + .toEqual([...expectedSources]); + } + expect(Store.get(context.plan.questions, id)?.suggested).toBeUndefined(); + expect( + context.plan.chat.entries.map(entry => + entry.decision?.kind === "prompt" ? entry.decision.sourceMessageIds : undefined + ), + ).toEqual([ + ["m5"], + ["m5", "m6"], + ["m5"], + [], + ["m9"], + [], + ]); + expect( + await runEffects( + { ...deps, applied: () => false }, + effectsFor([secondWithdrawal], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(context.plan.chat.entries).toHaveLength(6); + expect(context.plan.records.get(id)?.history).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/cards-3.test.ts b/apps/server/src/conversation-plan/cards-3.test.ts new file mode 100644 index 00000000..6616d40e --- /dev/null +++ b/apps/server/src/conversation-plan/cards-3.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from "bun:test"; + +import { cardEffects, mirrorCard, wakeCardMirror } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { PendingCardAction } from "../questions/card-actions"; +import type { Processor } from "./service"; +import { base, CARD, cardSource, OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a withdrawn proposer refreshes the stored advisory and durable Chat prompt to the surviving agreement", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let proposed: ConversationPlan.Event = { + id: "proposed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: 4, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: cardSource("m5", "Bram", "resolution"), + optionId: OPTION, + }; + let agreed: ConversationPlan.Event = { + ...proposed, + id: "agreed", + type: "settle.agreed", + source: cardSource("m6", "Ada", "support"), + }; + let withdrawn: ConversationPlan.Event = { + ...proposed, + id: "withdrawn", + type: "stance.changed", + scopedProposalId: null, + source: cardSource("m7", "Bram", "objection"), + position: "oppose", + }; + context.plan.conversationPlan.threads = [{ + ...thread(), + questionnaireId: id, + pendingSettle: { optionId: OPTION, proposer: "Bram", messageId: "m5" }, + }]; + context.plan.conversationPlan.events = [proposed]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let records = new Map([[id, { history: [] }]]); + let first = effectsFor([proposed], context.plan.conversationPlan, undefined, records); + expect(await runEffects(deps, first)).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m5"]); + context.plan.conversationPlan.events = [proposed, agreed, withdrawn]; + let latest = effectsFor([agreed, withdrawn], context.plan.conversationPlan, undefined, records); + expect(await runEffects(deps, latest)).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m6"]); + expect( + context.plan.chat.entries.map(entry => + entry.decision?.kind === "prompt" ? entry.decision.sourceMessageIds : undefined + ), + ).toEqual([ + ["m5"], + ["m6"], + ]); + expect(context.plan.records.get(id)?.history).toEqual([]); + let adaWithdrawn: ConversationPlan.Event = { + ...withdrawn, + id: "ada-withdrawn", + source: cardSource("m8", "Ada", "objection"), + }; + context.plan.conversationPlan.events.push(adaWithdrawn); + expect( + await runEffects( + deps, + effectsFor([adaWithdrawn], context.plan.conversationPlan, undefined, records), + ), + ).toBe(2); + expect(Store.get(context.plan.questions, id)?.suggested).toBeUndefined(); + expect(context.plan.chat.entries.at(-1)?.decision).toMatchObject({ sourceMessageIds: [] }); + expect(context.plan.records.get(id)?.history).toEqual([]); + expect(await runEffects({ ...deps, applied: () => false }, first)).toBe(2); + expect(context.plan.chat.entries).toHaveLength(3); +}); + +test("recovery mirrors held Save, Reopen, Save before card.linked and retries a failed first write", async () => { + let actions: PendingCardAction[] = [ + { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }, + { + ...base(), + id: `card:${CARD}:reopened:1`, + kind: "reopened", + generation: 1, + actor: "ben", + at: 10, + }, + { + ...base(), + id: `card:${CARD}:decided:2`, + kind: "decided", + generation: 2, + actor: "cy", + at: 11, + optionIds: [OPTION], + text: "GitHub Apps", + }, + ]; + let plan = { + pendingCardActions: structuredClone(actions), + conversationPlan: { ...initialState(), threads: [thread()] }, + } as Plan; + let fail = true; + let processor = { + record: async ( + make: (state: ConversationPlan.State) => ConversationPlan.Event | undefined, + id: string, + ) => { + if (fail) { + fail = false; + throw new Error("storage failed"); + } + expect(plan.pendingCardActions[0]!.id).toBe(id); + let event = make(plan.conversationPlan); + if (event) plan.conversationPlan = applyEvent(plan.conversationPlan, event); + plan.pendingCardActions = plan.pendingCardActions.slice(1); + return !!event; + }, + } as unknown as Processor; + await expect(mirrorCard(plan, processor)).rejects.toThrow("storage failed"); + expect(plan.pendingCardActions).toEqual(actions); + await mirrorCard(plan, processor); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.conversationPlan.threads[0]!.questionnaireId).toBeUndefined(); + expect(plan.conversationPlan.threads[0]!.decisionHistory.map(item => item.actor)).toEqual([ + { kind: "member", handle: "ana" }, + { kind: "member", handle: "cy" }, + ]); + expect(plan.conversationPlan.events.map(item => item.id)).toEqual(actions.map(item => item.id)); +}); + +test("normal room wake retries one failed mirror without spinning", async () => { + let action: PendingCardAction = { + ...base(), + id: `card:${CARD}:decided:1`, + kind: "decided", + generation: 1, + optionIds: [OPTION], + text: "GitHub Apps", + }; + let plan = { + pendingCardActions: [action], + conversationPlan: { ...initialState(), threads: [thread()] }, + } as Plan; + let attempts = 0; + let wakes = 0; + let processor = { + wake() { + wakes++; + }, + record: async (make: (state: ConversationPlan.State) => ConversationPlan.Event, id: string) => { + attempts++; + if (attempts === 1) throw new Error("storage unavailable"); + expect(plan.pendingCardActions[0]?.id).toBe(id); + plan.conversationPlan = applyEvent(plan.conversationPlan, make(plan.conversationPlan)); + plan.pendingCardActions = []; + return true; + }, + } as unknown as Processor; + await expect(mirrorCard(plan, processor)).rejects.toThrow("storage unavailable"); + expect(plan.pendingCardActions).toEqual([action]); + expect(attempts).toBe(1); + await wakeCardMirror(plan, processor); + expect(wakes).toBe(1); + expect(attempts).toBe(2); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.conversationPlan.threads[0]?.decisionHistory[0]?.text).toBe("GitHub Apps"); +}); diff --git a/apps/server/src/conversation-plan/cards-4.test.ts b/apps/server/src/conversation-plan/cards-4.test.ts new file mode 100644 index 00000000..05785d38 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-4.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; + +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; + +import { openPlan } from "../testing/plan"; + +import type { Processor } from "./service"; +import { CARD, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a full card is terminal and does not block a later quoted option", async () => { + let context = await openPlan(); + plans.push(context.plan); + let full = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "full-thread", + header: "Full", + question: "Which?", + options: Array.from({ length: 10 }, (_, index) => ({ + id: ulid(), + label: `Choice ${index}`, + })), + }); + let open = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "open-thread", + header: "Open", + question: "Which?", + options: [], + }); + context.plan.conversationPlan.threads = [ + { ...thread(), id: "full-thread", questionnaireId: full, contributions: [] }, + { ...thread(), id: "open-thread", questionnaireId: open, contributions: [] }, + ]; + let accepted = new Set(); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let first = ulid(); + let second = ulid(); + let completed = await runEffects({ + ...commands, + applied: key => accepted.has(key), + markApplied: async key => { + accepted.add(key); + }, + }, [ + { + key: `option:${first}`, + kind: "add-option", + threadId: "full-thread", + optionId: first, + label: "Too many", + trigger: "m1", + }, + { + key: `option:${second}`, + kind: "add-option", + threadId: "open-thread", + optionId: second, + label: "GitHub Apps", + trigger: "m2", + }, + ]); + expect(completed).toBe(2); + expect(accepted).toEqual(new Set([`option:${first}`, `option:${second}`])); + expect(context.plan.records.get(full)!.definition.questions[0]!.options).toHaveLength(10); + expect(context.plan.records.get(open)!.definition.questions[0]!.options).toMatchObject([{ + id: second, + label: "GitHub Apps", + }]); + expect(context.plan.pendingCardActions).toEqual([]); + let record = context.plan.records.get(open)!; + context.plan.records.set(open, { + ...record, + definition: { + questions: [{ + ...record.definition.questions[0]!, + options: [{ ...record.definition.questions[0]!.options[0]!, label: "Refined" }], + }], + }, + }); + await commands.addOption(open, { optionId: second, label: "GitHub Apps" }); + expect(context.plan.records.get(open)!.definition.questions[0]!.options[0]!.label) + .toBe("Refined"); + context.plan.execution = { id: "run-1" } as never; + await expect(commands.addOption(open, { optionId: ulid(), label: "Deferred" })) + .rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + expect(Store.reserveEdit(context.plan.questions, open)).toBe(true); + await expect(commands.addOption(open, { optionId: ulid(), label: "Busy" })) + .rejects.toThrow("temporarily unavailable"); + Store.releaseEdit(context.plan.questions, open); +}); + +test("closed-card prose readiness requires the linked thread and saved generation", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Decision", + question: "Which?", + options: [], + }); + let record = context.plan.records.get(id)!; + context.plan.records.set(id, { + ...record, + status: "answered", + owner: "ana", + decidedAt: 1, + } as never); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + expect(commands.proseReady?.("thread-a", id, `decided:${id}:1`)).toBe(true); + expect(commands.proseReady?.("other-thread", id, `decided:${id}:1`)).toBe(false); + expect(commands.proseReady?.("thread-a", "other-card", `decided:${id}:1`)).toBe(false); + expect(commands.proseReady?.("thread-a", id, `decided:${id}:2`)).toBe(false); +}); + +test("a linked but missing card defers insertion instead of creating a second card", async () => { + let context = await openPlan(); + plans.push(context.plan); + context.plan.conversationPlan.threads = [{ + ...thread(), + id: "missing-thread", + questionnaireId: CARD, + }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + await expect(commands.insertCard({ + threadId: "missing-thread", + header: "Auth", + question: "Which?", + options: [], + })).rejects.toThrow("linked card is missing"); + expect(context.plan.records.size).toBe(0); +}); diff --git a/apps/server/src/conversation-plan/cards-5.test.ts b/apps/server/src/conversation-plan/cards-5.test.ts new file mode 100644 index 00000000..583e724b --- /dev/null +++ b/apps/server/src/conversation-plan/cards-5.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { type Effect, runEffects } from "./effects"; + +import * as Questions from "../questions/service"; + +import { openPlan } from "../testing/plan"; + +import type { Processor } from "./service"; +import { OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a delayed old agreement cannot become a fresh prompt after Save and Reopen", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => void receipts.add(key), + }; + let effect = (name: string, generation: number): Effect => ({ + key: `prompt:${name}`, + kind: "prompt", + threadId: "thread-a", + optionId: OPTION, + messageId: name, + generation, + }); + expect(await runEffects(deps, [effect("first", 0)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0]); + let ws = { + data: { handle: "ana", client: "client", room: "test" }, + send() {}, + publish() {}, + } as never; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(context.plan.records.get(id)).toMatchObject({ status: "reopened", history: [{}] }); + expect(await runEffects(deps, [effect("delayed-old", 0)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0]); + expect(await runEffects(deps, [effect("fresh", 1)])).toBe(1); + expect(context.plan.chat.entries.map(entry => entry.decision?.generation)).toEqual([0, 1]); +}); + +test("failed prompt persistence and receipt retry produce one durable notice", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + context.plan.conversationPlan.threads = [{ ...thread(), questionnaireId: id }]; + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let original = context.storage.collaboration.commit; + let failCommit = true; + context.storage.collaboration.commit = async input => { + if (failCommit) { + failCommit = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + let receipts = new Set(); + let errors: unknown[] = []; + let failReceipt = true; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (failReceipt && context.plan.chat.entries.length) { + failReceipt = false; + throw new Error("receipt unavailable"); + } + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + let prompt: Effect = { + key: "prompt:agreement", + kind: "prompt", + threadId: "thread-a", + optionId: OPTION, + messageId: "agreement", + generation: 0, + }; + expect(await runEffects(deps, [prompt])).toBe(0); + expect(context.plan.chat.entries).toEqual([]); + expect(receipts.size).toBe(0); + expect(await runEffects(deps, [prompt])).toBe(0); + expect(context.plan.chat.entries).toHaveLength(1); + expect(receipts.size).toBe(0); + expect(await runEffects(deps, [prompt])).toBe(1); + expect(context.plan.chat.entries).toHaveLength(1); + expect(context.plan.chat.entries[0]?.decision).toMatchObject({ + questionnaireId: id, + kind: "prompt", + generation: 0, + }); + expect(receipts).toEqual(new Set(["prompt:agreement"])); + expect(errors.map(String)).toEqual(["Error: storage unavailable", "Error: receipt unavailable"]); +}); diff --git a/apps/server/src/conversation-plan/cards-6.test.ts b/apps/server/src/conversation-plan/cards-6.test.ts new file mode 100644 index 00000000..474601c0 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-6.test.ts @@ -0,0 +1,181 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; + +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { OPTION, plans } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a scoped choice notice keeps every agreement across retries and delayed effects", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let opened: ConversationPlan.Event = { + id: "scoped-open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which auth system?", + }; + let state = applyEvent(initialState(), opened); + state = applyEvent(state, { + id: "scoped-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + questionnaireId: id, + }); + let quote = "I'd pick GitHub Apps for the spike;"; + let proposal: ConversationPlan.Event = { + id: "scoped-proposal", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: { + messageId: "scoped-m6", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "support", + }, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + state = applyEvent(state, proposal); + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: "scoped-m6", + author: { kind: "member", handle: "mei" }, + text: quote, + ts: 2, + }); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let effects = effectsFor([proposal], state, undefined, context.plan.records); + let receipts = new Set(); + let failReceipt = true; + let errors: unknown[] = []; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (failReceipt) { + failReceipt = false; + throw new Error("receipt unavailable"); + } + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + expect(await runEffects(deps, effects)).toBe(0); + expect(context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice")) + .toHaveLength(1); + expect(await runEffects(deps, effects)).toBe(1); + expect(context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice")) + .toHaveLength(1); + expect(context.plan.chat.entries.at(-1)?.decision).toMatchObject({ + kind: "scoped-choice", + proposalId: proposal.id, + triggerEventId: proposal.id, + generation: 0, + sources: [proposal.source], + }); + expect(receipts).toEqual(new Set([effects[0]!.key])); + expect(errors.map(String)).toEqual(["Error: receipt unavailable"]); + + let agreementQuote = "yep, GitHub Apps for the spike."; + let firstAgreement: ConversationPlan.Event = { + id: "scoped-agreement-rob", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-m7", + author: { kind: "member", handle: "rob" }, + quote: agreementQuote, + start: 0, + end: agreementQuote.length, + role: "support", + }, + proposalId: proposal.id, + cardId: id, + optionId: OPTION, + label: "GitHub Apps", + scope: "spike", + }; + state = applyEvent(state, firstAgreement); + let secondAgreement: ConversationPlan.Event = { + ...firstAgreement, + id: "scoped-agreement-ana", + observedThreadVersion: state.threads[0]!.version, + at: 4, + source: { + messageId: "scoped-m8", + author: { kind: "member", handle: "ana" }, + quote: agreementQuote, + start: 0, + end: agreementQuote.length, + role: "support", + }, + }; + state = applyEvent(state, secondAgreement); + context.plan.conversationPlan = state; + for (let agreement of [firstAgreement, secondAgreement]) { + if (agreement.type !== "scoped-choice.agreed") throw new Error("agreement missing"); + context.plan.chat.entries.push({ + id: agreement.source.messageId, + author: agreement.source.author, + text: agreement.source.quote, + ts: agreement.at, + }); + } + let newer = effectsFor([secondAgreement], state, undefined, context.plan.records); + let older = effectsFor([firstAgreement], state, undefined, context.plan.records); + expect(await runEffects(deps, newer)).toBe(1); + expect(await runEffects(deps, older)).toBe(1); + let notices = context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice"); + expect(notices).toHaveLength(1); + let decision = notices[0]?.decision; + if (decision?.kind !== "scoped-choice") throw new Error("scoped notice missing"); + expect(decision.sources).toEqual([ + proposal.source, + firstAgreement.source, + secondAgreement.source, + ]); + expect(state.events.filter(event => event.type === "scoped-choice.agreed")).toHaveLength(2); + expect(state.threads[0]?.status).toBe("exploring"); +}); diff --git a/apps/server/src/conversation-plan/cards-7.test.ts b/apps/server/src/conversation-plan/cards-7.test.ts new file mode 100644 index 00000000..567a7905 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-7.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; + +import { cardEffects } from "./cards"; +import { runEffects } from "./effects"; +import { effectsFor } from "./effects"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; + +import type { Processor } from "./service"; +import { OPTION, plans, scopedNoticeFixture } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("scoped notices for distinct card options keep separate identities", async () => { + let context = await openPlan(); + plans.push(context.plan); + let otherOption = "01K0N4W3B7P27CBAEC7A8C8WEC"; + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which auth system?", + options: [ + { id: OPTION, label: "GitHub Apps" }, + { id: otherOption, label: "Auth0" }, + ], + }); + let state = applyEvent(initialState(), { + id: "scoped-open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Which auth system?", + }); + state = applyEvent(state, { + id: "scoped-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + questionnaireId: id, + }); + let commands = cardEffects( + context.plan, + context.server, + "test", + { record: async () => true } as unknown as Processor, + undefined, + { chat: context.plan.chat, plan: context.plan, server: context.server, room: "test" }, + ); + let receipts = new Set(); + let errors: unknown[] = []; + let deps = { + ...commands, + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + receipts.add(key); + }, + report: (error: unknown) => errors.push(error), + }; + for ( + let [index, option] of [ + { id: OPTION, label: "GitHub Apps" }, + { id: otherOption, label: "Auth0" }, + ].entries() + ) { + let quote = `I'd pick ${option.label} for the spike;`; + let proposal: ConversationPlan.Event = { + id: `scoped-proposal-${index}`, + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: index + 2, + source: { + messageId: `scoped-message-${index}`, + author: { kind: "member", handle: index === 0 ? "mei" : "ana" }, + quote, + start: 0, + end: quote.length, + role: "support", + }, + cardId: id, + optionId: option.id, + label: option.label, + scope: "spike", + }; + state = applyEvent(state, proposal); + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: proposal.source.messageId, + author: proposal.source.author, + text: quote, + ts: proposal.at, + }); + expect(await runEffects(deps, effectsFor([proposal], state, undefined, context.plan.records))) + .toBe(1); + } + let notices = context.plan.chat.entries.filter(entry => entry.decision?.kind === "scoped-choice"); + expect(notices).toHaveLength(2); + expect( + notices.map(entry => + entry.decision?.kind === "scoped-choice" ? entry.decision.optionId : undefined + ), + ).toEqual([OPTION, otherOption]); + expect(new Set(notices.map(entry => entry.id)).size).toBe(2); + expect(state.threads[0]?.status).toBe("exploring"); + expect(errors).toEqual([]); + let unrelated = notices[1]!; + let unchanged = structuredClone(unrelated.decision); + let quote = "I don't support that choice anymore."; + let opposition: ConversationPlan.Event = { + id: "first-option-proposer-opposes", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "first-option-opposition", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }; + state = applyEvent(state, opposition); + await Service.exclusive(context.plan, async () => { + context.plan.conversationPlan = state; + context.plan.chat.entries.push({ + id: opposition.source.messageId, + author: opposition.source.author, + text: quote, + ts: opposition.at, + }); + await Service.persistExclusive(context.plan); + }); + await runEffects(deps, effectsFor([opposition], state, undefined, context.plan.records)); + expect(context.plan.chat.entries.find(entry => entry.id === unrelated.id)?.decision) + .toEqual(unchanged); +}); + +test("a later non-support stance removes only its speaker from the scoped notice", async () => { + let fixture = await scopedNoticeFixture(true); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + expect(before?.decision).toMatchObject({ + sources: [fixture.proposal.source, fixture.agreement.source], + }); + let quote = "I oppose GitHub Apps now."; + await fixture.deliver({ + id: "rob-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "scoped-rob-opposes", + author: { kind: "member", handle: "rob" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + optionId: OPTION, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices).toHaveLength(1); + expect(notices[0]?.id).toBe(before?.id); + expect(notices[0]?.decision).toMatchObject({ sources: [fixture.proposal.source] }); + expect(fixture.errors).toEqual([]); +}); + +test("a proposer's later opposition leaves no actionable scoped Save prompt", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I oppose GitHub Apps now."; + await fixture.deliver({ + id: "mei-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-opposes", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + optionId: OPTION, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices.length === 0 || !fixture.state().threads[0]?.pendingScopedChoice).toBe(true); + expect(fixture.errors).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/cards-8.test.ts b/apps/server/src/conversation-plan/cards-8.test.ts new file mode 100644 index 00000000..ab0ef044 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-8.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test"; + +import { restoreState } from "./domain"; + +import { OPTION, scopedNoticeFixture } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("optionless non-support refreshes only the active supporter's scoped notice", async () => { + let fixture = await scopedNoticeFixture(true); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "rob-optionless-opposition", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + source: { + messageId: "scoped-rob-optionless", + author: { kind: "member", handle: "rob" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices).toHaveLength(1); + expect(notices[0]?.id).toBe(before.id); + expect(notices[0]?.decision).toMatchObject({ sources: [fixture.proposal.source] }); + expect(fixture.state().events.at(-1)?.id).toBe("rob-optionless-opposition"); + expect(fixture.errors).toEqual([]); +}); + +test("optionless proposer non-support retires a scoped Save with no other support", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "mei-optionless-opposition", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-optionless", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let notices = fixture.context.plan.chat.entries.filter(entry => + entry.decision?.kind === "scoped-choice" + ); + expect(notices.length === 0 || !fixture.state().threads[0]?.pendingScopedChoice).toBe(true); + expect(fixture.state().events.at(-1)?.id).toBe("mei-optionless-opposition"); + expect(fixture.errors).toEqual([]); +}); + +test("legacy persisted optionless withdrawal still retires scoped Save on replay", async () => { + let fixture = await scopedNoticeFixture(false); + let quote = "I don't support that choice anymore."; + await fixture.deliver({ + id: "mei-legacy-withdrawal", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: { + messageId: "scoped-mei-legacy", + author: { kind: "member", handle: "mei" }, + quote, + start: 0, + end: quote.length, + role: "objection", + }, + position: "oppose", + }); + let stored = structuredClone(fixture.state()); + let legacy = stored.events.at(-1); + if (!legacy || legacy.type !== "stance.changed") throw new Error("withdrawal missing"); + delete legacy.scopedProposalId; + let restored = restoreState(stored, fixture.context.plan.chat.entries); + expect(restored.events.at(-1)).not.toHaveProperty("scopedProposalId"); + expect(restored.threads[0]?.pendingScopedChoice).toBeUndefined(); +}); + +for ( + let scenario of [ + { + name: "an unrelated optionless objection by a supporter keeps scoped evidence", + supporter: true, + id: "rob-toolbar-objection", + messageId: "scoped-rob-toolbar", + actor: "rob", + quote: "I oppose making the toolbar blue.", + at: 4, + optionId: undefined, + }, + { + name: "an unrelated optionless objection by the proposer keeps scoped Save active", + supporter: false, + id: "mei-toolbar-objection", + messageId: "scoped-mei-toolbar", + actor: "mei", + quote: "I oppose making the toolbar blue.", + at: 3, + optionId: undefined, + }, + { + name: "an unlinked named-option objection keeps the scoped Save notice active", + supporter: false, + id: "mei-unlinked-named-objection", + messageId: "scoped-mei-unlinked-named", + actor: "mei", + quote: "I oppose GitHub Apps.", + at: 3, + optionId: OPTION, + }, + ] +) { + test(scenario.name, async () => { + let fixture = await scopedNoticeFixture(scenario.supporter); + let before = fixture.context.plan.chat.entries.find(entry => + entry.decision?.kind === "scoped-choice" + ); + if (!before) throw new Error("scoped notice missing"); + let previous = structuredClone(before.decision); + await fixture.deliver({ + id: scenario.id, + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: fixture.state().threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: scenario.at, + source: { + messageId: scenario.messageId, + author: { kind: "member", handle: scenario.actor }, + quote: scenario.quote, + start: 0, + end: scenario.quote.length, + role: "objection", + }, + ...(scenario.optionId ? { optionId: scenario.optionId } : {}), + position: "oppose", + }); + expect(fixture.state().threads[0]?.pendingScopedChoice?.proposalId) + .toBe(fixture.proposal.id); + expect(fixture.context.plan.chat.entries.find(entry => entry.id === before.id)?.decision) + .toEqual(previous); + expect(fixture.errors).toEqual([]); + }); +} diff --git a/apps/server/src/conversation-plan/cards-9.test.ts b/apps/server/src/conversation-plan/cards-9.test.ts new file mode 100644 index 00000000..268be958 --- /dev/null +++ b/apps/server/src/conversation-plan/cards-9.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; + +import { mirrorCard } from "./cards"; + +import { initialState } from "./domain"; + +import * as Questions from "../questions/service"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createProcessor } from "./service"; +import { OPTION, plans, thread } from "./cards.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("live card listeners mirror a reopen emitted inside the room lock without deadlock", async () => { + let context = await openPlan(); + plans.push(context.plan); + context.plan.conversationPlan = { ...initialState(), threads: [thread()] }; + let processor = createProcessor({ + plan: context.plan, + exclusive: action => Service.exclusive(context.plan, action), + persist: () => Service.persistExclusive(context.plan), + publish: () => {}, + active: () => true, + }); + let errors: unknown[] = []; + let off = Questions.listen(context.plan, () => { + void mirrorCard(context.plan, processor).catch(error => errors.push(error)); + }); + try { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Auth", + question: "Which?", + options: [{ id: OPTION, label: "GitHub Apps" }], + }); + let actor = "ana"; + let ws = { + data: { + get handle() { + return actor; + }, + client: "client", + room: "test", + }, + send() {}, + publish() {}, + } as never; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-1", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + actor = "ben"; + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m2"], + }), + ).toBe(true); + actor = "cy"; + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-2", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + for (let attempt = 0; attempt < 100 && context.plan.pendingCardActions.length; attempt++) { + await Bun.sleep(1); + } + expect(errors).toEqual([]); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlan.events.map(event => event.id)).toEqual([ + `card:${id}:decided:1`, + `card:${id}:reopened:1`, + `card:${id}:decided:2`, + ]); + expect(context.plan.conversationPlan.threads[0]!.decisionHistory.map(item => item.actor)) + .toEqual([ + { kind: "member", handle: "ana" }, + { kind: "member", handle: "cy" }, + ]); + } finally { + off(); + processor.stop(); + } +}); diff --git a/apps/server/src/conversation-plan/cards.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/job-context.test.ts b/apps/server/src/conversation-plan/job-context.test.ts new file mode 100644 index 00000000..e331d6bc --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.test.ts @@ -0,0 +1,326 @@ +import { expect, test } from "bun:test"; + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; +import { createJobContexts } from "./job-context"; +import { initialState } from "./domain"; +import { applyEvent } from "./events"; +import { effectsFor } from "./effects"; + +function plan(owner?: string): Parameters["remember"]>[0] { + return { + chat: { owner: owner ? { sessionId: owner } : undefined }, + conversationPlan: initialState(), + conversationPlanJobs: [], + conversationPlanPendingEffects: [], + pendingCardActions: [], + persistence: { committedSidecar: {} }, + }; +} + +function context(sessionId: string): Chat.Room { + return { claimantSessionId: sessionId } as Chat.Room; +} + +function job(trigger: string): Job { + return { + id: `refine:W1:${trigger}`, + kind: "refine", + target: "W1", + trigger, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("a job claims its original member after more than 64 later messages", () => { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [job("m0")]; + let original = context("original-session"); + contexts.remember(opened, "m0", original); + for (let index = 1; index <= 80; index++) { + contexts.remember(opened, `m${index}`, context(`other-${index}`)); + } + expect(contexts.claim(opened, job("m0"))).toEqual({ + context: original, + claimantSessionId: "original-session", + }); + expect(contexts.claim(opened, job("missing"))).toBeUndefined(); +}); + +test("a failed accept removes only its own captured context", () => { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [job("old-message")]; + let old = context("old"); + let failed = context("failed"); + contexts.remember(opened, "old-message", old); + contexts.remember(opened, "new-message", failed); + contexts.forget(opened, "new-message", failed); + expect(contexts.claim(opened, job("new-message"))).toBeUndefined(); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(old); + let replacement = context("replacement"); + let prior = contexts.remember(opened, "old-message", replacement); + contexts.forget(opened, "old-message", old); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(replacement); + contexts.restore(opened, "old-message", replacement, prior); + expect(contexts.claim(opened, job("old-message"))?.context).toBe(old); +}); + +test("a duplicate-ID accept failure restores the first accepted claimant", async () => { + let contexts = createJobContexts(); + let opened = plan(); + let accepted = context("accepted-session"); + let duplicate = context("duplicate-session"); + await contexts.accept(opened, "same-message", accepted, async () => { + opened.conversationPlanJobs = [job("same-message")]; + }); + await expect(contexts.accept(opened, "same-message", duplicate, async () => { + throw new Error("duplicate message"); + })).rejects.toThrow("duplicate message"); + expect(contexts.claim(opened, job("same-message"))).toEqual({ + context: accepted, + claimantSessionId: "accepted-session", + }); +}); + +test("current owner wins credentials without borrowing a later member's session", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + opened.conversationPlanJobs = [job("m1")]; + let original = context("original"); + contexts.remember(opened, "m1", original); + contexts.remember(opened, "m2", context("later")); + expect(contexts.claim(opened, job("m1"))).toEqual({ + context: original, + claimantSessionId: "owner", + }); + expect(contexts.claim(opened, job("missing"))).toBeUndefined(); + let owner = context("owner"); + contexts.remember(opened, "owner-message", owner); + expect(contexts.claim(opened, job("missing"))).toEqual({ + context: owner, + claimantSessionId: "owner", + }); +}); + +test("a prose job requires its winning Save claimant even when another owner context exists", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + contexts.remember(opened, "old-message", context("owner")); + let prose: Job = { + ...job("decided:W1:2"), + id: "prose:W1:decided:W1:2", + kind: "prose", + }; + expect(contexts.claim(opened, prose)).toBeUndefined(); + opened.conversationPlanJobs = [prose]; + let saver = context("save-session"); + contexts.remember(opened, prose.trigger, saver); + expect(contexts.claim(opened, prose)).toEqual({ + context: saver, + claimantSessionId: "owner", + }); +}); + +test("accepted messages without unfinished work release their original job contexts", async () => { + let contexts = createJobContexts(); + let opened = plan(); + for (let index = 0; index < 1000; index++) { + await contexts.accept(opened, `m${index}`, context(`member-${index}`), async () => {}); + } + expect(contexts.claim(opened, job("m0"))).toBeUndefined(); + expect(contexts.claim(opened, job("m998"))).toBeUndefined(); + expect(contexts.any(opened)?.claimantSessionId).toBe("member-999"); +}); + +test("completed and skipped jobs release contexts while retryable jobs retain them", () => { + for (let status of ["pending", "running", "failed", "done", "skipped"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlanJobs = [{ ...job("original"), status }]; + let original = context("original-member"); + contexts.remember(opened, "original", original); + for (let index = 0; index < 80; index++) { + contexts.remember(opened, `later-${index}`, context("later")); + } + expect(contexts.claim(opened, job("original"))?.context).toBe( + status === "done" || status === "skipped" ? undefined : original, + ); + opened.conversationPlanJobs = []; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("queued and failed analysis retain their member until the queue entry is consumed", () => { + for (let status of ["pending", "processing", "failed"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + opened.conversationPlan.queue = [{ messageId: "original", status, attempts: 1 }]; + let original = context("original-member"); + contexts.remember(opened, "original", original); + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, job("original"))?.context).toBe(original); + opened.conversationPlan.queue = []; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("winning Save context survives the pending action and job-effect handover", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + let trigger = "decided:W1:2"; + let prose: Job = { ...job(trigger), kind: "prose" }; + let saver = context("saver"); + opened.pendingCardActions = [{ + id: "card:W1:decided:2", + kind: "decided", + cardId: "W1", + threadId: "t1", + actor: "saver", + at: 1, + generation: 2, + optionIds: [], + text: "Use Alpha", + }]; + contexts.remember(opened, trigger, saver); + contexts.remember(opened, "owner", context("owner")); + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.pendingCardActions = []; + opened.conversationPlanPendingEffects = [{ + key: "prose-effect", + kind: "job", + threadId: "t1", + intent: { kind: "prose", target: "W1", trigger }, + }]; + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.conversationPlanPendingEffects = []; + opened.conversationPlanJobs = [prose]; + expect(contexts.claim(opened, prose)?.context).toBe(saver); + opened.conversationPlanJobs = [{ ...prose, status: "done" }]; + expect(contexts.claim(opened, prose)).toBeUndefined(); +}); + +test("the current owner fallback survives later members and replaces older owner contexts", () => { + let contexts = createJobContexts(); + let opened = plan("owner"); + contexts.remember(opened, "old-owner", context("owner")); + let current = context("owner"); + contexts.remember(opened, "current-owner", current); + contexts.remember(opened, "later-member", context("later")); + expect(contexts.claim(opened, job("missing"))?.context).toBe(current); + opened.chat.owner = undefined; + expect(contexts.claim(opened, job("current-owner"))).toBeUndefined(); + expect(contexts.any(opened)?.claimantSessionId).toBe("later"); +}); + +test("acceptance retains its claimant while another message commits before its queue entry", async () => { + let contexts = createJobContexts(); + let opened = plan(); + let original = context("original"); + let release!: () => void; + let waiting = new Promise(resolve => { + release = resolve; + }); + let accepting = contexts.accept(opened, "first", original, async () => { + await waiting; + opened.conversationPlan.queue = [{ messageId: "first", status: "pending", attempts: 0 }]; + }); + await contexts.accept(opened, "second", context("second"), async () => {}); + expect(contexts.claim(opened, job("first"))?.context).toBe(original); + release(); + await accepting; + expect(contexts.claim(opened, job("first"))?.context).toBe(original); + opened.conversationPlan.queue = []; + expect(contexts.claim(opened, job("first"))).toBeUndefined(); +}); + +test("pruning preserves committed roots while a removal is staged and can roll back", () => { + for (let root of ["analysis", "job", "effect"] as const) { + let contexts = createJobContexts(); + let opened = plan(); + if (root === "analysis") { + opened.conversationPlan.queue = [{ + messageId: "original", + status: "processing", + attempts: 1, + }]; + } else if (root === "job") { + opened.conversationPlanJobs = [job("original")]; + } else { + opened.conversationPlanPendingEffects = [{ + key: "refine-effect", + kind: "job", + intent: { kind: "refine", target: "W1", trigger: "original" }, + }]; + } + let committed = { + conversationPlan: opened.conversationPlan, + conversationPlanJobs: opened.conversationPlanJobs, + conversationPlanPendingEffects: opened.conversationPlanPendingEffects, + pendingCardActions: [], + }; + opened.persistence.committedSidecar = JSON.parse(JSON.stringify(committed)); + let original = context("original"); + contexts.remember(opened, "original", original); + opened.conversationPlan = initialState(); + opened.conversationPlanJobs = []; + opened.conversationPlanPendingEffects = []; + contexts.remember(opened, "later", context("later")); + Object.assign(opened, committed); + expect(contexts.claim(opened, job("original"))?.context).toBe(original); + opened.conversationPlan = initialState(); + opened.conversationPlanJobs = []; + opened.conversationPlanPendingEffects = []; + opened.persistence.committedSidecar = {}; + expect(contexts.claim(opened, job("original"))).toBeUndefined(); + } +}); + +test("card insertion keeps the original question claimant until linking creates its refine effect", () => { + let contexts = createJobContexts(); + let opened = plan(); + let source = { + messageId: "question-message", + author: { kind: "member", handle: "alice" } as const, + quote: "Which editor?", + start: 0, + end: 13, + role: "question" as const, + }; + let event = { + id: "opened-event", + type: "thread.opened" as const, + threadId: "t1", + observedThreadVersion: 0, + origin: "classifier" as const, + actor: { kind: "classifier" } as const, + at: 1, + question: source.quote, + source, + }; + opened.conversationPlan = applyEvent(opened.conversationPlan, event); + opened.conversationPlanPendingEffects = effectsFor([event], opened.conversationPlan); + let original = context("question-author"); + contexts.remember(opened, source.messageId, original); + contexts.remember(opened, "later", context("later")); + expect(contexts.claim(opened, job(source.messageId))?.context).toBe(original); + let linked = { + id: "linked-event", + type: "card.linked" as const, + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier" as const, + actor: { kind: "classifier" } as const, + at: 2, + questionnaireId: "W1", + }; + opened.conversationPlan = applyEvent(opened.conversationPlan, linked); + opened.conversationPlanPendingEffects = effectsFor([linked], opened.conversationPlan); + expect(contexts.claim(opened, job(source.messageId))?.context).toBe(original); + opened.conversationPlanPendingEffects = []; + expect(contexts.claim(opened, job(source.messageId))).toBeUndefined(); +}); diff --git a/apps/server/src/conversation-plan/job-context.ts b/apps/server/src/conversation-plan/job-context.ts new file mode 100644 index 00000000..b7515af5 --- /dev/null +++ b/apps/server/src/conversation-plan/job-context.ts @@ -0,0 +1,162 @@ +/** + * Process-local Chat contexts for the member message that started a job. + * Retain claimants while durable work can still use them, plus the current + * owner and latest authenticated context for jobs without a captured trigger. + */ + +import type * as Chat from "../chat/service"; +import type { Job } from "./jobs"; +import type { Plan as OpenPlan } from "../plan/service"; +import { proseJobTrigger } from "../questions/card-actions"; + +type Work = Pick< + OpenPlan, + | "conversationPlan" + | "conversationPlanJobs" + | "conversationPlanPendingEffects" + | "pendingCardActions" +>; +type Plan = Work & { + chat: { owner?: { sessionId: string } }; + persistence: Pick; +}; + +export function createJobContexts() { + let byPlan = new WeakMap>(); + let accepting = new WeakMap>(); + + function prune(plan: Plan): void { + let byMessage = byPlan.get(plan); + if (!byMessage) return; + let needed = new Set(accepting.get(plan)?.keys()); + function retain(work: Partial): void { + for (let item of work.conversationPlan?.queue ?? []) needed.add(item.messageId); + for (let job of work.conversationPlanJobs ?? []) { + if (job.status === "pending" || job.status === "running" || job.status === "failed") { + needed.add(job.trigger); + } + } + for (let action of work.pendingCardActions ?? []) { + if (action.kind === "decided") { + needed.add(proseJobTrigger(action.cardId, action.generation)); + } + } + for (let effect of work.conversationPlanPendingEffects ?? []) { + if (effect.kind === "job") needed.add(effect.intent.trigger); + if ("source" in effect && effect.source) needed.add(effect.source.messageId); + if ("sources" in effect) { + for (let source of effect.sources) needed.add(source.messageId); + } + if ("messageId" in effect) needed.add(effect.messageId); + if ("messageIds" in effect) { + for (let id of effect.messageIds) needed.add(id); + } + if (effect.kind === "prompt") { + for (let id of effect.sourceMessageIds ?? []) needed.add(id); + } + if (effect.kind === "insert-card") { + // Linking the card creates a refine job from the original question source. + let thread = work.conversationPlan?.threads.find(item => item.id === effect.threadId); + let source = thread?.questionSources[0]; + if (source) needed.add(source.messageId); + } + } + } + retain(plan); + // Live fields can remove work before its fenced commit; keep the rollback roots too. + let committed = plan.persistence.committedSidecar; + if (committed && typeof committed === "object" && !Array.isArray(committed)) { + retain(committed as Partial); + } + let entries = [...byMessage]; + let latest = entries.at(-1); + let owner = entries.findLast(([, context]) => + context.claimantSessionId === plan.chat.owner?.sessionId + ); + if (latest) needed.add(latest[0]); + if (owner) needed.add(owner[0]); + for (let id of byMessage.keys()) { + if (!needed.has(id)) byMessage.delete(id); + } + } + + return { + remember(plan: Plan, messageId: string, context: Chat.Room): Chat.Room | undefined { + let byMessage = byPlan.get(plan) ?? new Map(); + byPlan.set(plan, byMessage); + let previous = byMessage.get(messageId); + byMessage.delete(messageId); + byMessage.set(messageId, context); + prune(plan); + return previous; + }, + async accept( + plan: Plan, + messageId: string, + context: Chat.Room, + commit: () => Promise, + ): Promise { + let byMessage = byPlan.get(plan) ?? new Map(); + byPlan.set(plan, byMessage); + let previous = byMessage.get(messageId); + let pending = accepting.get(plan) ?? new Map(); + accepting.set(plan, pending); + pending.set(messageId, (pending.get(messageId) ?? 0) + 1); + byMessage.delete(messageId); + byMessage.set(messageId, context); + prune(plan); + try { + return await commit(); + } catch (error) { + if (byMessage.get(messageId) === context) { + if (previous) byMessage.set(messageId, previous); + else byMessage.delete(messageId); + } + throw error; + } finally { + let count = pending.get(messageId)! - 1; + if (count) pending.set(messageId, count); + else pending.delete(messageId); + prune(plan); + } + }, + forget(plan: Plan, messageId: string, context: Chat.Room): void { + let byMessage = byPlan.get(plan); + if (byMessage?.get(messageId) === context) byMessage.delete(messageId); + }, + restore( + plan: Plan, + messageId: string, + context: Chat.Room, + previous?: Chat.Room, + ): void { + let byMessage = byPlan.get(plan); + if (byMessage?.get(messageId) !== context) return; + if (previous) byMessage.set(messageId, previous); + else byMessage.delete(messageId); + }, + clear(plan: Plan): void { + byPlan.delete(plan); + accepting.delete(plan); + }, + any(plan: Plan): Chat.Room | undefined { + prune(plan); + return [...(byPlan.get(plan)?.values() ?? [])].at(-1); + }, + claim(plan: Plan, job: Job): { context: Chat.Room; claimantSessionId: string } | undefined { + prune(plan); + let byMessage = byPlan.get(plan); + let triggered = byMessage?.get(job.trigger); + let owner = plan.chat.owner?.sessionId; + if (job.kind === "prose" && !triggered) return undefined; + if (owner) { + let context = triggered + ?? [...(byMessage?.values() ?? [])].findLast(item => item.claimantSessionId === owner); + return context ? { context, claimantSessionId: owner } : undefined; + } + return triggered + ? { context: triggered, claimantSessionId: triggered.claimantSessionId } + : undefined; + }, + }; +} diff --git a/apps/server/src/conversation-plan/job-prompts-sources.test.ts b/apps/server/src/conversation-plan/job-prompts-sources.test.ts new file mode 100644 index 00000000..1a74afc5 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts-sources.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; + +import { refinePrompt } from "./job-prompts"; + +test("a question mention exposes its exact source without treating it as support", () => { + let quote = "Lexical or ProseMirror 🤖?"; + let ref = { + messageId: "m1", + author: { kind: "member", handle: "mina" }, + quote, + start: 12, + end: 12 + quote.length, + role: "question", + }; + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which editor?", + options: [], + outline: "", + revision: 1, + thread: { questionSources: [ref], contributions: [], stances: [] } as never, + }); + let line = prompt.split("\n").find(line => line.startsWith("- question_source=")); + expect(JSON.parse(line!.slice("- question_source=".length))).toEqual(ref); + expect(prompt).toContain("it does not\nendorse either choice"); + expect(prompt).toContain( + "Keep a question-sourced\noption label faithful to the named alternative", + ); + expect(prompt).toContain("do not add specificity that the\nquestion did not name"); +}); + +test("source evidence is bounded by complete citations, never a sliced JSON span", () => { + let sources = Array.from({ length: 20 }, (_, index) => ({ + messageId: `m${index}`, + author: { kind: "member", handle: "fawkes" }, + quote: `Option ${index} ${"a".repeat(700)}`, + start: 0, + end: 708 + String(index).length, + role: "option", + })); + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: sources.map((source, index) => ({ + kind: "option", + text: `Option ${index}`, + sources: [source], + })), + stances: [], + } as never, + }); + let evidence = prompt.split("What people said:\n")[1]!.split("\n\n")[0]!; + expect(evidence.length).toBeLessThanOrEqual(6000); + expect(evidence).toContain('"messageId":"m19"'); + for (let line of evidence.split("\n")) { + let cited = JSON.parse(line.slice(line.indexOf("source=") + "source=".length)); + expect(cited.quote).toBe(sources[Number(cited.messageId.slice(1))]?.quote); + } +}); + +test("unsourced evidence does not acquire a fabricated citation", () => { + let prompt = refinePrompt({ + kind: "suggest", + id: "W1", + question: "Which storage approach?", + options: [], + outline: "0 paragraph sha256:123 PostgreSQL is installed", + revision: 1, + thread: { + contributions: [{ kind: "reason", text: "Repository uses PostgreSQL", sources: [] }], + stances: [], + } as never, + }); + expect(prompt).toContain("- reason: Repository uses PostgreSQL"); + expect(prompt).not.toContain("source={"); + expect(prompt).toContain("For repository/document evidence, omit"); +}); + +test("reason and constraint citations cannot be copied as option provenance", () => { + let sources = ["reason", "constraint"].map((role, index) => ({ + messageId: `m${index}`, + author: { kind: "member", handle: "mina" }, + quote: role === "reason" ? "Needs keyboard support" : "Avoid a new dependency", + start: 0, + end: role === "reason" ? 22 : 22, + role, + })); + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: sources.map(source => ({ + kind: source.role, + text: source.quote, + sources: [source], + })), + stances: [], + } as never, + }); + expect(prompt).toContain("context_source="); + expect(prompt).not.toContain("option_source="); + expect(prompt).toContain("Never copy a"); + expect(prompt).toContain("Those may inform"); +}); diff --git a/apps/server/src/conversation-plan/job-prompts.test.ts b/apps/server/src/conversation-plan/job-prompts.test.ts new file mode 100644 index 00000000..844eb819 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; + +import { headingPrompt, refinePrompt } from "./job-prompts"; + +test("the heading prompt quotes recent members and names its one writing tool", () => { + let prompt = headingPrompt({ + transcript: [ + { + id: "m1", + author: { kind: "member", handle: "mina" }, + text: "Okay we gotta figure out auth", + ts: 1, + }, + { id: "m2", author: { kind: "system" }, text: "Sam joined", ts: 2 }, + ], + outline: "", + }); + expect(prompt).toContain("- @mina: Okay we gotta figure out auth"); + expect(prompt).not.toContain("Sam joined"); + expect(prompt).toContain("call `draft_heading` once"); + expect(prompt).toContain("Use no other writing tool. Do not reply in chat."); +}); + +test("a suggest prompt forbids retitling and moving", () => { + let prompt = refinePrompt({ + kind: "suggest", + id: "W1", + question: "What auth system should we use?", + options: ["Auth0", "GitHub Apps"], + outline: "0 heading sha256:123", + revision: 3, + }); + expect(prompt).toContain("1. Auth0\n2. GitHub Apps"); + expect(prompt).toContain("Do not change the title and do not move the card."); + expect(prompt).toContain("call `refine_decision` exactly once with id W1"); +}); + +test("a refine prompt includes bounded evidence and card option labels", () => { + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Where should auth live?", + options: ["Auth0", "GitHub Apps"], + outline: "0 heading sha256:123", + revision: 3, + thread: { + contributions: [{ + kind: "reason", + text: `Use the repository identity ${"x".repeat(2000)}`, + sources: [{ + messageId: "m1", + quote: "Use the repository identity", + start: 0, + end: 27, + role: "reason", + author: { + kind: "member", + handle: "mina", + }, + }], + }], + stances: [{ participant: "sam", position: "support", optionId: "o2", sources: [] }], + } as never, + }); + expect(prompt).toContain("What people said:"); + expect(prompt).toContain( + 'context_source={"messageId":"m1","author":{"kind":"member","handle":"mina"},', + ); + expect(prompt).toContain('"quote":"Use the repository identity","start":0,"end":27,'); + expect(prompt).toContain("- @sam supports o2"); + expect(prompt).toContain("Reword the title to ask which approach the team should choose"); + expect(prompt).toContain("Split mutually exclusive alternatives"); + expect(prompt).toContain("including slash lists"); + expect(prompt).toContain("Compare the underlying approaches, not just exact labels"); + expect(prompt).toContain("Do not invent a source or rationale"); + expect(prompt).toContain( + "Only a displayed `option_source` with role `option` or a `question_source`", + ); + expect(prompt).toContain("`context_source` from a reason, constraint, or stance"); + expect(prompt).toContain("For repository/document evidence, omit"); + expect(prompt).toContain("unclear, omit that addition"); + expect(prompt).toContain("place_after"); + expect(prompt).not.toContain("x".repeat(1000)); +}); + +test("a refine prompt preserves exact UTF-16 source offsets and whitespace", () => { + let span = "Tiptap or\nProseMirror 🤖"; + let source = { + messageId: "m-emoji", + author: { kind: "member", handle: "fawkes" }, + quote: span, + start: 7, + end: 7 + span.length, + role: "option", + }; + let prompt = refinePrompt({ + kind: "refine", + id: "W1", + question: "Which approach should we use for highlighting?", + options: [], + outline: "", + revision: 1, + thread: { + contributions: [{ kind: "option", text: span, sources: [source] }], + stances: [], + } as never, + }); + let line = prompt.split("\n").find(line => line.startsWith("- option:")); + expect(line).toBeDefined(); + expect(line).toContain("option_source="); + let cited = JSON.parse(line!.slice(line!.indexOf("source=") + "source=".length)); + expect(cited).toEqual(source); + expect(cited.end - cited.start).toBe(span.length); + expect(prompt).toContain("omit `source` when neither exact source is shown"); +}); diff --git a/apps/server/src/conversation-plan/job-prompts.ts b/apps/server/src/conversation-plan/job-prompts.ts new file mode 100644 index 00000000..4858b3c4 --- /dev/null +++ b/apps/server/src/conversation-plan/job-prompts.ts @@ -0,0 +1,186 @@ +/** Fixed, bounded instructions for one background Planner job. */ + +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import * as edit from "../plan/edit"; +import { proseInput, prosePrompt } from "./prose-job"; +import type { Plan } from "../plan/service"; + +function quote(value: string, max = 500): string { + return value.replace(/\s+/g, " ").trim().slice(0, max); +} + +function source(ref: ConversationPlan.SourceRef | undefined): string { + if (!ref) return ""; + let label = ref.role === "option" ? "option_source" : ref.role === "question" + ? "question_source" + : "context_source"; + return ` ${label}=${ + JSON.stringify({ + messageId: ref.messageId, + author: ref.author, + quote: ref.quote, + start: ref.start, + end: ref.end, + role: ref.role, + }) + }`; +} + +function boundedLines(lines: string[], max: number): string { + let kept: string[] = []; + let used = 0; + for (let index = lines.length - 1; index >= 0; index--) { + let line = lines[index]!; + let length = line.length + (kept.length ? 1 : 0); + if (used + length > max) continue; + kept.unshift(line); + used += length; + } + return kept.join("\n"); +} + +function outline(plan: Plan): string { + return edit.outline(plan).slice(0, 50) + .map(block => `${block.index} ${block.type} ${block.digest} ${quote(block.preview, 120)}`) + .join("\n"); +} + +export function headingPrompt(input: { transcript: Chat.Entry[]; outline: string }): string { + let said = input.transcript + .filter(entry => entry.author.kind === "member") + .slice(-15) + .map(entry => + `- @${quote((entry.author as Extract).handle, 80)}: ${ + quote(entry.text) + }` + ) + .join("\n"); + return [ + "[Background job: heading]", + "People have started discussing a new, empty document. Give it a title and a one-sentence goal.", + "", + "Recent conversation:", + said || "(none)", + "", + "Current document outline:", + input.outline.slice(0, 6000) || "(empty)", + "", + "Call `read_plan`, then call `draft_heading` once with its revision, a short title (at most", + "80 characters, no `#`) and a goal of one sentence in the team's own terms, starting", + '"Goal:". Describe what they are trying to decide, not a decision they have not made.', + "Use no other writing tool. Do not reply in chat.", + ].join("\n"); +} + +export function refinePrompt(input: { + kind: "refine" | "suggest"; + id: string; + question: string; + options: string[]; + thread?: ConversationPlan.Thread; + outline: string; + revision: number; +}): string { + let { kind, thread } = input; + let labels = new Map( + thread?.contributions.filter(item => item.kind === "option") + .map(item => [item.id, quote(item.text, 200)] as const) ?? [], + ); + let evidence = boundedLines( + thread?.contributions.slice(-20).map(item => + `- ${item.kind}: ${quote(item.text)}${source(item.sources[0])}` + ) ?? [], + 6000, + ); + let questionSources = boundedLines( + thread?.questionSources?.slice(-20).map(ref => `- ${source(ref).trim()}`) ?? [], + 4000, + ); + let stances = boundedLines( + thread?.stances.slice(-20).map(stance => + `- @${quote(stance.participant, 80)} ${stance.position}s ${ + labels.get(stance.optionId ?? "") ?? quote(stance.optionId ?? "the question", 200) + }${source(stance.sources[0])}` + ) ?? [], + 4000, + ); + let task = kind === "refine" + ? [ + "1. Reword the title to ask which approach the team should choose for the actual task,", + " not which options exist or are installed. Keep the meaning, use at most 80 characters,", + ' and end with "?".', + "2. Add options only when the discussion, the repository or the document justifies them.", + "3. If the card belongs after a particular block of prose, give `place_after` with that", + " block's index and digest from `read_plan`. Otherwise leave it where it is.", + ] + : [ + "People added options in chat. Consider whether an obvious, sensible option is still", + "missing. Do not change the title and do not move the card.", + ]; + return [ + `[Background job: ${kind}]`, + `Decision card ${quote(input.id, 200)}: "${quote(input.question)}"`, + "", + "Options so far:", + input.options.slice(0, 10).map((label, index) => `${index + 1}. ${quote(label, 200)}`) + .join("\n") || "(none)", + "", + ...(questionSources ? ["Question mentions:", questionSources, ""] : []), + ...(evidence ? ["What people said:", evidence, ""] : []), + ...(stances ? ["Positions:", stances, ""] : []), + "Document outline:", + input.outline.slice(0, 6000) || "(empty)", + "", + ...task, + "", + "Each option must name one actionable approach. Split mutually exclusive alternatives", + "mentioned together (such as A or B or C, including slash lists) into separate options.", + "Keep related APIs that", + "form one approach together. Compare the underlying approaches, not just exact labels,", + "against existing options and each other; do not add a reworded or grouped duplicate.", + "For each addition, write a one-sentence rationale tied to a specific conversation source", + "above or to identifiable document/repository evidence. If the evidence or the split is", + "unclear, omit that addition. Do not invent a source or rationale.", + "Only a displayed `option_source` with role `option` or a `question_source` with role", + "`question` may be copied unchanged into `add_options[].source`, including UTF-16", + "start/end and exact quote. A question mention names possibilities; it does not", + "endorse either choice. Use it as provenance only when the quote names the option", + "with the option's own terms, not as evidence of support. Keep a question-sourced", + "option label faithful to the named alternative; do not add specificity that the", + "question did not name. Never copy a", + "`context_source` from a reason, constraint, or stance into that field. Those may inform", + "the rationale, but omit `source` when neither exact source is shown. Do not derive", + "offsets or quotes from the shortened summary. For repository/document evidence, omit", + "`source`.", + "The card holds at most 10 options. Adding nothing is a good result when nothing is missing.", + `Call \`read_plan\`, then call \`refine_decision\` exactly once with id ${ + quote(input.id, 200) + }.`, + "Use no other writing tool. Do not reply in chat.", + ].join("\n"); +} + +function card(plan: Plan, job: ConversationPlan.Job, kind: "refine" | "suggest"): string { + let record = plan.records.get(job.target); + let question = record?.definition.questions[0]; + let thread = plan.conversationPlan.threads.find(item => item.questionnaireId === job.target); + return refinePrompt({ + kind, + id: job.target, + question: question?.question ?? "", + options: question?.options.map(option => option.label) ?? [], + thread, + outline: outline(plan), + revision: plan.revision, + }); +} + +export const PROMPT_FOR: Record< + ConversationPlan.JobKind, + ((plan: Plan, job: ConversationPlan.Job) => string) | undefined +> = { + heading: plan => headingPrompt({ transcript: plan.chat.entries, outline: outline(plan) }), + refine: (plan, job) => card(plan, job, "refine"), + suggest: (plan, job) => card(plan, job, "suggest"), + prose: (plan, job) => prosePrompt(proseInput(plan, job.target)), +}; diff --git a/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts b/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts new file mode 100644 index 00000000..a7287092 --- /dev/null +++ b/apps/server/src/conversation-plan/job-scripted-refine-retry.test-fixtures.ts @@ -0,0 +1,172 @@ +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Jobs from "./jobs"; +import { authenticatedMemory } from "../chat/job-authenticated-memory.test-fixtures"; +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { createPlannerJobs } from "./planner-jobs"; +import { createScriptedHarness } from "./scripted-harness.test-driver"; +import { PROMPT_FOR } from "./job-prompts"; +import type { openPlan } from "../testing/plan"; + +type Opened = Awaited>; + +export async function scriptedRefine( + opened: Opened, + dir: string, + id: string, + authentication?: Awaited>, +) { + let plan = opened.plan; + async function saved() { + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + if (!stored) throw new Error("missing durable document"); + let state = stored.sidecar; + if (!state || typeof state !== "object" || Array.isArray(state)) { + throw new Error("missing durable sidecar"); + } + if (!Array.isArray(state.questions) || !Array.isArray(state.transcript)) { + throw new Error("missing durable card or transcript"); + } + Jobs.restore(state.conversationPlanJobs); + return { + source: (await Plan.readStored(stored)).source, + record: state.questions.map(Questions.normalizeRecord).find(record => record.id === id), + jobs: structuredClone(state.conversationPlanJobs ?? []) as typeof plan.conversationPlanJobs, + transcript: state.transcript, + }; + } + let latest = await saved(); + let commits: Array = []; + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let result = await commit(input); + latest = await saved(); + commits.push(latest); + return result; + }; + let publications: Array<{ frame: Record; saved: typeof latest }> = []; + let publish = opened.server.publish; + opened.server.publish = (...args) => { + publications.push({ + frame: JSON.parse(String(args[1])), + saved: structuredClone(latest), + }); + return publish(...args); + }; + let identity = authentication ?? await authenticatedMemory(opened, "refine"); + let owners = new ActiveOwnerBindings(identity.context.auth); + let context: Chat.Room = { + ...identity.context, + plan, + chat: plan.chat, + persist: () => Plan.persist(plan), + activeOwner: () => owners.resolve(opened.channel.id), + }; + let resultSnapshots: Array< + Promise<{ output: unknown; saved: Awaited> }> + > = []; + let driver = createScriptedHarness(dir, plan, { + onResult: async result => { + let snapshot = saved().then(saved => ({ output: result.output, saved })); + resultSnapshots.push(snapshot); + await snapshot; + }, + }); + let agent = createPlannerAgent(driver.fake, "refine"); + let sessionId: string | undefined; + let setups = 0; + let unexpectedCommands = 0; + let sandboxDestroys = 0; + let credentialReleases = 0; + context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + refineAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run(options: { + command: string; + env?: Record; + abortSignal?: AbortSignal; + }) { + let directory = `/tmp/${encodeURIComponent(driver.fake.harnessId)}-${ + encodeURIComponent(sessionId ?? "") + }`; + if ( + sessionId && setups === 0 && options.command === 'mkdir -p "$WORK_DIR"' + && Object.keys(options.env ?? {}).length === 1 + && options.env?.WORK_DIR === directory + && Object.keys(options).every(key => ["command", "env", "abortSignal"].includes(key)) + ) { + setups++; + return { exitCode: 0, stdout: "", stderr: "" }; + } + unexpectedCommands++; + throw new Error("unexpected scripted sandbox command"); + }, + async destroy() { + sandboxDestroys++; + }, + }) as never, + registerCredential: id => { + sessionId = id; + return () => { + credentialReleases++; + }; + }, + }); + let errors: unknown[] = []; + let jobs = createPlannerJobs({ + plan, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + runner: (job, prompt, signal) => Chat.job(context, job, prompt, identity.sessionId, signal), + prompt: job => PROMPT_FOR[job.kind]?.(plan, job), + publishJobs: current => + opened.server.publish( + opened.channel.id, + JSON.stringify({ + kind: "conversation-plan:jobs", + jobs: current, + }), + ), + publishMeta: target => Questions.announce(plan, opened.server, opened.channel.id, target), + activity: async text => { + await Chat.noticeExclusive(context, text); + }, + onError: error => errors.push(error), + }); + let closing: Promise | undefined; + return { + plan, + jobs, + driver, + identity, + errors, + saved, + results: () => Promise.all(resultSnapshots), + publications, + commits, + counts: () => ({ setups, unexpectedCommands, sandboxDestroys, credentialReleases }), + close() { + return closing ??= (async () => { + jobs.stop(); + owners.revokeAll(); + identity.revokeAll(); + await jobs.idle(); + await plan.chat.running; + try { + await Promise.all(resultSnapshots); + await Plan.close(plan); + } finally { + opened.storage.collaboration.commit = commit; + opened.server.publish = publish; + } + })(); + }, + }; +} diff --git a/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts b/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts new file mode 100644 index 00000000..fe593491 --- /dev/null +++ b/apps/server/src/conversation-plan/job-scripted-refine-retry.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { scriptedRefine } from "./job-scripted-refine-retry.test-fixtures"; + +const TITLE = "Which authentication approach should the pilot use?"; +const LABEL = "GitHub Apps"; +const RATIONALE = "It keeps the pilot small and concrete."; + +function script(id: string) { + return [{ + tool: "refine_decision", + args: { + revision: "$revision", + id, + title: TITLE, + add_options: [{ label: LABEL, rationale: RATIONALE }], + }, + }]; +} + +test("a failed scripted refine survives reopen and only an explicit corrected-file retry runs it", async () => { + let dir = await mkdtemp(join(tmpdir(), "scripted-refine-retry-")); + let opened = await openPlan("Opening prose.\n\nLater prose.\n"); + let plan = opened.plan; + let current: Awaited> | undefined; + try { + let id = await Questions.insertConversationCard(plan, opened.server, opened.channel.id, { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options: [], + }); + plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "We need to choose authentication.", + ts: 1, + }); + await Plan.persist(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + let baseline = structuredClone(plan.records.get(id)!); + let source = Plan.source(plan); + let revision = plan.revision; + let draft = Store.get(plan.questions, id)!; + let draftRevision = draft.revision; + await writeFile(join(dir, "refine.json"), JSON.stringify(script("not-this-card"))); + current = await scriptedRefine({ ...opened, plan }, dir, id); + let first = current; + expect(first.driver.starts()).toBe(0); + expect(first.counts()).toEqual({ + setups: 0, + unexpectedCommands: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + await first.jobs.enqueue({ kind: "refine", target: id, trigger: "m1" }); + await first.jobs.idle(); + let failed = structuredClone(plan.conversationPlanJobs[0]!); + expect(failed).toMatchObject({ + id: `refine:${id}:m1`, + target: id, + trigger: "m1", + status: "failed", + attempts: 1, + reason: "The Planner ended without calling refine_decision.", + }); + expect(first.driver.results).toMatchObject([{ + toolName: "refine_decision", + output: "Error: This job refines a different decision.", + }]); + expect(first.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(first.driver.starts()).toBe(1); + expect(first.driver.destroyed()).toBe(1); + expect(first.identity.driver.starts()).toBe(0); + expect(plan.records.get(id)).toEqual(baseline); + expect(Plan.source(plan)).toBe(source); + expect(plan.revision).toBe(revision); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + let failureResult = (await first.results())[0]!; + expect(failureResult.saved.record).toEqual(baseline); + expect(failureResult.saved.source).toBe(source); + expect(failureResult.saved.transcript).toHaveLength(1); + let failedPublication = first.publications.find(({ frame }) => + frame.kind === "conversation-plan:jobs" + && (frame.jobs as Array<{ status: string }>)[0]?.status === "failed" + )!; + expect(failedPublication.saved.jobs).toEqual([failed]); + expect(failedPublication.saved.record).toEqual(baseline); + expect(failedPublication.saved.transcript).toHaveLength(1); + expect(first.errors).toEqual([]); + expect(first.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + await first.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + current = undefined; + expect(plan.conversationPlanJobs).toEqual([failed]); + expect(plan.records.get(id)).toEqual(baseline); + expect(Plan.source(plan)).toBe(source); + current = await scriptedRefine({ ...opened, plan }, dir, id, first.identity); + let retry = current; + retry.jobs.wake(); + await retry.jobs.idle(); + expect(retry.driver.starts()).toBe(0); + expect(retry.driver.destroyed()).toBe(0); + expect(plan.conversationPlanJobs).toEqual([failed]); + await writeFile(join(dir, "refine.json"), JSON.stringify(script("$target"))); + expect(await retry.jobs.retry(failed.id)).toBe(true); + await retry.jobs.idle(); + let done = plan.conversationPlanJobs[0]!; + expect(done).toMatchObject({ + id: failed.id, + kind: failed.kind, + target: failed.target, + trigger: failed.trigger, + status: "done", + attempts: 2, + }); + expect(done.reason).toBeUndefined(); + expect(JSON.parse(done.output!)).toEqual({ title: TITLE, added: 1 }); + let record = structuredClone(plan.records.get(id)!); + let question = record.definition.questions[0]!; + expect(question.question).toBe(TITLE); + expect(question.options).toHaveLength(1); + let option = question.options[0]!; + expect(option.id).toBeTruthy(); + expect(option.label).toBe(LABEL); + expect(record.optionOrigins[option.id]).toEqual({ origin: "planner", rationale: RATIONALE }); + let successfulResult = (await retry.results())[0]!; + expect(String(successfulResult.output)).toContain('"ok": true'); + expect(successfulResult.saved.record).toEqual(record); + expect(successfulResult.saved.source).toBe(Plan.source(plan)); + expect(successfulResult.saved.jobs).toMatchObject([{ status: "running", attempts: 1 }]); + expect(successfulResult.saved.transcript).toHaveLength(1); + let donePublication = retry.publications.find(({ frame }) => + frame.kind === "conversation-plan:jobs" + && (frame.jobs as Array<{ status: string }>)[0]?.status === "done" + )!; + expect(donePublication.saved.jobs).toEqual([done]); + expect(donePublication.saved.record).toEqual(record); + expect(donePublication.saved.source).toBe(Plan.source(plan)); + expect(donePublication.saved.transcript).toMatchObject([ + { id: "m1", text: "We need to choose authentication." }, + { author: { kind: "system" }, text: `Chopin refined ${TITLE}` }, + ]); + expect(donePublication.saved.transcript).toHaveLength(2); + expect(retry.driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES.refine]]); + expect(retry.driver.calls).toMatchObject([{ + toolName: "refine_decision", + input: { id, title: TITLE }, + }]); + expect(retry.driver.starts()).toBe(1); + expect(retry.driver.destroyed()).toBe(1); + expect(retry.identity.driver.starts()).toBe(0); + expect(retry.counts()).toEqual({ + setups: 1, + unexpectedCommands: 0, + sandboxDestroys: 1, + credentialReleases: 1, + }); + expect(retry.errors).toEqual([]); + expect(plan.chat.job).toBeUndefined(); + expect(plan.chat.turn).toBeUndefined(); + expect(plan.chat.entries.some(entry => entry.tools?.length || entry.streaming)).toBe(false); + expect( + opened.broadcasts.some(frame => ["chat:tool", "chat:delta"].includes(String(frame.kind))), + ) + .toBe(false); + for (let observation of [...first.publications, ...retry.publications]) { + if (observation.frame.kind === "question:meta") { + expect(observation.frame.id).toBe(id); + expect(observation.frame.meta).toMatchObject({ + status: observation.saved.record!.status, + origin: observation.saved.record!.origin, + optionOrigins: observation.saved.record!.optionOrigins, + refining: observation.saved.jobs.some(job => + job.kind === "refine" && ["pending", "running"].includes(job.status) + ), + }); + } + if (observation.frame.kind === "question:changed") { + expect(observation.frame.definition).toEqual(observation.saved.record!.definition); + } + } + let changed = retry.publications.filter(({ frame }) => frame.kind === "question:changed"); + expect(changed).toHaveLength(2); + expect(changed.at(-1)!.frame.definition).toEqual(record.definition); + let metas = retry.publications.filter(({ frame }) => frame.kind === "question:meta"); + expect(metas.at(-1)!.frame.meta).toMatchObject({ + refining: false, + optionOrigins: record.optionOrigins, + }); + let completedSource = Plan.source(plan); + await retry.close(); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + current = undefined; + expect(plan.records.get(id)).toEqual(record); + expect(plan.conversationPlanJobs).toEqual([done]); + expect(Plan.source(plan)).toBe(completedSource); + expect(plan.chat.entries).toHaveLength(2); + expect(plan.chat.waiting).toEqual([]); + current = await scriptedRefine({ ...opened, plan }, dir, id, first.identity); + current.jobs.wake(); + await current.jobs.idle(); + expect(current.driver.starts()).toBe(0); + expect(current.driver.destroyed()).toBe(0); + expect(current.counts()).toEqual({ + setups: 0, + unexpectedCommands: 0, + sandboxDestroys: 0, + credentialReleases: 0, + }); + expect(await current.jobs.retry(done.id)).toBe(false); + } finally { + if (current) await current.close(); + else if (!plan.chat.closed) await Plan.close(plan); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/conversation-plan/option-admission.test.ts b/apps/server/src/conversation-plan/option-admission.test.ts new file mode 100644 index 00000000..63d0e943 --- /dev/null +++ b/apps/server/src/conversation-plan/option-admission.test.ts @@ -0,0 +1,530 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { applyInference, completeAnalysis, enqueue, initialState } from "./domain"; +import { cardEffects, mirrorCard } from "./cards"; +import { createProcessor } from "./service"; +import { entry, opened } from "./service.test-fixtures"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Socket } from "../wire"; +import type { Dependencies } from "./processor-types"; +import type { JsonValue } from "../storage/model"; + +async function setup(optionCount: number, reasons = 0, interpret?: Dependencies["interpret"]) { + let context = await openPlan(); + let { plan, server } = context; + let question = entry("question", "Where should we host?"); + let excerpt = { + ...entry("excerpt", "Use the new provider."), + author: { kind: "member" as const, handle: "alice" }, + }; + plan.chat.entries = [question, excerpt]; + let state = applyInference(initialState(), opened(question), question); + let options = Array.from({ length: optionCount }, (_, index) => ({ + id: `01K0N4W3B7P27CBAEC7A8C8WE${index}`, + label: `Provider ${index}`, + })); + for ( + let [index, text] of [ + ...options.map(option => option.label), + ...Array.from({ length: reasons }, (_, i) => `Reason ${i}`), + ].entries() + ) { + let message = entry(`evidence-${index}`, text); + plan.chat.entries.push(message); + let kind: "option" | "reason" = index < options.length ? "option" : "reason"; + state = applyInference(state, { + id: `event-${index}`, + type: `${kind}.added`, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: text, + start: 0, + end: text.length, + role: kind, + }, + contribution: { + id: options[index]?.id ?? `reason-${index}`, + text, + authoring: "quoted", + targetId: "thread-a", + }, + }, message); + } + plan.conversationPlan = completeAnalysis(enqueue(state, excerpt.id), excerpt.id, [], excerpt, { + questionSetVersion: "conversation-plan-5", + modelVersion: "fixture", + status: "unlinked", + passes: [], + outcomes: [{ + start: 0, + end: excerpt.text.length, + status: "review", + gate: "unclassified excerpt", + eventIds: [], + }], + }); + await Plan.persist(plan); + let cardId = await Questions.insertConversationCard(plan, server, plan.id, { + threadId: "thread-a", + header: "Hosting", + question: question.text, + options, + }); + let errors: unknown[] = []; + let processor = createProcessor({ + plan, + interpret, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + active: () => true, + publish() {}, + onError: error => errors.push(error), + }); + let effects = cardEffects(plan, server, plan.id, processor, undefined, { + chat: plan.chat, + plan, + server, + room: plan.id, + }); + await effects.link("thread-a", cardId); + await processor.idle(); + let frames: Array> = []; + let ws = { + data: { handle: "carol", client: "carol", room: plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { + ...context, + cardId, + processor, + effects, + errors, + frames, + ws, + correct(kind: "option" | "reason" = "option") { + return processor.correct({ + actionId: "add-excerpt", + threadId: "thread-a", + expectedVersion: plan.conversationPlan.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: excerpt.id, + start: 0, + end: excerpt.text.length, + contributionKind: kind, + }, + }, { kind: "member", handle: "bob" }); + }, + append(label = "Another provider") { + return Questions.addOption(plan, server, plan.id, ws, { + kind: "question:option", + rid: "append", + ts: 0, + id: cardId, + question: Store.get(plan.questions, cardId)!.definition.questions[0]!.id, + key: "new-option", + label, + }); + }, + async save() { + let live = Store.get(plan.questions, cardId)!; + let model = live.model.fork(); + model.api.val([live.definition.questions[0]!.id, "choice"]).set(options[0]!.id); + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "select", + ts: 0, + id: cardId, + patch: [...model.api.flush()!.toBinary()], + }); + await Questions.submit(plan, server, plan.id, ws, { + kind: "question:submit", + rid: "save", + ts: 0, + id: cardId, + revision: Store.get(plan.questions, cardId)!.revision, + }); + }, + snapshot() { + return structuredClone({ + record: plan.records.get(cardId), + draft: Store.snapshot(plan.questions, cardId), + conversation: plan.conversationPlan, + source: Room.project(plan.document), + pending: plan.pendingCardActions, + pendingEffects: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + broadcasts: context.broadcasts, + storageRevision: plan.persistence.revision, + }); + }, + async close() { + processor.stop(); + await processor.idle(); + await Plan.close(plan); + }, + }; +} + +test("a full card refuses human option correction before claiming it was added", async () => { + let h = await setup(10); + try { + let before = h.snapshot(); + await expect(h.correct()).rejects.toThrow(/full|limit/i); + expect(h.snapshot()).toEqual(before); + expect(h.plan.conversationPlanPendingEffects.some(effect => effect.kind === "add-option")).toBe( + false, + ); + } finally { + await h.close(); + } +}); + +test("a full discussion refuses an option before acknowledgement and still permits Save", async () => { + let h = await setup(1, 63); + try { + let before = h.snapshot(); + await h.append(); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(h.snapshot()).toEqual(before); + await h.save(); + await mirrorCard(h.plan, h.processor); + expect(h.plan.records.get(h.cardId)?.status).toBe("answered"); + expect(h.plan.conversationPlan.threads[0]?.status).toBe("decided"); + expect(h.plan.pendingCardActions).toEqual([]); + } finally { + await h.close(); + } +}); + +test("a pending option mirror reserves the last discussion slot against corrections", async () => { + let h = await setup(1, 62); + try { + await h.append(); + expect(h.frames.at(-1)?.ok).toBe(true); + let before = h.snapshot(); + await expect(h.correct("reason")).rejects.toThrow(/contribution limit/i); + expect(h.snapshot()).toEqual(before); + await mirrorCard(h.plan, h.processor); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(64); + expect(h.plan.pendingCardActions).toEqual([]); + } finally { + await h.close(); + } +}); + +test("inference cannot consume a pending option mirror's reserved slot", async () => { + let h = await setup(1, 62, async ({ state, message }) => ({ + events: [{ + id: "reason:inferred", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "reason", + }, + contribution: { + id: "reason-inferred", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }], + analysis: { + questionSetVersion: "conversation-plan-5", + modelVersion: "fixture", + status: "applied", + passes: [], + }, + })); + try { + await h.append(); + let message = entry("more-evidence", "This provider is cheaper."); + await h.processor.accept(message); + h.processor.afterMessage(); + await h.processor.idle(); + expect(h.plan.conversationPlan.queue.find(item => item.messageId === message.id)?.status).toBe( + "failed", + ); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(63); + await mirrorCard(h.plan, h.processor); + expect(h.plan.conversationPlan.threads[0]?.contributions).toHaveLength(64); + expect(h.plan.pendingCardActions).toEqual([]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("an accepted excerpt reserves the last card slot across another writer and reopen", async () => { + let h = await setup(9); + let restored: Plan.Plan | undefined; + let recovered: ReturnType | undefined; + try { + let reply = await h.correct(); + await h.processor.idle(); + let before = h.snapshot(); + await h.append(); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(h.snapshot()).toEqual(before); + let event = h.plan.conversationPlan.events.find(item => item.id === reply.eventId)!; + if (event.type !== "option.added") throw new Error("missing accepted option"); + let key = `option:${event.contribution.id}`; + await h.close(); + restored = await Plan.open(h.plan.id, h.backend, h.server); + let plan = restored; + recovered = createProcessor({ + plan, + exclusive: action => Plan.exclusive(plan, action), + persist: () => Plan.persistExclusive(plan), + active: () => true, + publish() {}, + }); + recovered.setEffects( + cardEffects(plan, h.server, plan.id, recovered, undefined, { + chat: plan.chat, + plan, + server: h.server, + room: plan.id, + }), + ); + await recovered.idle(); + expect(plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(10); + expect(plan.records.get(h.cardId)?.definition.questions[0]?.options.at(-1)?.id).toBe( + event.contribution.id, + ); + expect(plan.conversationPlanEffects).toContain(key); + expect(plan.conversationPlanPendingEffects.some(effect => effect.key === key)).toBe(false); + } finally { + recovered?.stop(); + await recovered?.idle(); + if (restored) await Plan.close(restored); + else await h.close(); + } +}); + +test("a classifier option ahead of a reserved human option cannot block its delivery", async () => { + let h = await setup(9); + try { + let message = entry("classifier-option", "An inferred provider"); + h.plan.chat.entries.push(message); + await h.processor.record({ + id: "classifier-option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: h.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "option", + }, + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEA", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }); + await h.correct(); + h.processor.setEffects(h.effects); + await h.processor.idle(); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options.at(-1)?.label).toBe( + "Use the new provider.", + ); + expect(h.plan.conversationPlanPendingEffects.filter(effect => effect.kind === "add-option")) + .toEqual([]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("an accepted excerpt reserves its label against another writer", async () => { + let h = await setup(1); + try { + await h.correct(); + let before = h.snapshot(); + await h.append("USE THE NEW PROVIDER."); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "duplicate" }); + expect(h.snapshot()).toEqual(before); + } finally { + await h.close(); + } +}); + +test("a Save after delivery target lookup supersedes the pending human option", async () => { + let h = await setup(1); + try { + let { runEffects } = await import("./effects"); + await h.correct(); + let option = h.plan.conversationPlanPendingEffects.find(effect => + effect.kind === "add-option" + )!; + let receipts: string[] = []; + let failures: unknown[] = []; + await runEffects({ + ...h.effects, + applied: () => false, + addOption: async (id, input) => { + await h.save(); + await h.effects.addOption(id, input); + }, + markApplied: async key => { + receipts.push(key); + }, + report: error => failures.push(error), + }, [option]); + expect(failures).toEqual([]); + expect(receipts).toEqual([option.key]); + expect(h.plan.records.get(h.cardId)?.status).toBe("answered"); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(1); + } finally { + await h.close(); + } +}); + +test("refused legacy human option delivery retains pending work without an applied receipt", async () => { + let h = await setup(10); + try { + // Recreate an outbox accepted by the previous implementation at an already-full card. + let { applyCorrection } = await import("./domain"); + let { effectsFor, runEffects } = await import("./effects"); + let next = applyCorrection( + h.plan.conversationPlan, + { + actionId: "legacy-full", + threadId: "thread-a", + expectedVersion: h.plan.conversationPlan.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: "excerpt", + start: 0, + end: "Use the new provider.".length, + contributionKind: "option", + }, + }, + { kind: "member", handle: "bob" }, + 1001, + h.plan.chat.entries, + ); + h.plan.conversationPlan = next; + let pending = effectsFor([next.events.at(-1)!], next, undefined, h.plan.records); + let receipts: string[] = []; + let failures: unknown[] = []; + await runEffects({ + ...h.effects, + applied: key => receipts.includes(key), + markApplied: async key => { + receipts.push(key); + }, + report: error => failures.push(error), + }, pending); + expect(failures).toHaveLength(1); + expect(String(failures[0])).toContain("accepted excerpt option: full"); + expect(receipts).toEqual([]); + expect(h.plan.records.get(h.cardId)?.definition.questions[0]?.options).toHaveLength(10); + } finally { + await h.close(); + } +}); + +test("a legacy over-reserved discussion reopens and permits unrelated commits without more overflow", async () => { + let h = await setup(1, 62); + let restored: Plan.Plan | undefined; + try { + await h.append(); + let message = entry("legacy-last-reason", "Evidence accepted before reservations existed."); + let saved = structuredClone(h.plan.persistence.committedSidecar) as { + [key: string]: JsonValue; + }; + let legacy = applyInference(h.plan.conversationPlan, { + id: "legacy-reason", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: h.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "reason", + }, + contribution: { + id: "legacy-reason", + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, message); + saved.conversationPlan = JSON.parse(JSON.stringify(legacy)); + saved.transcript = JSON.parse(JSON.stringify([...h.plan.chat.entries, message])); + await h.close(); + // Write the prior release's valid durable shape, bypassing today's admission guard. + let durable = h.plan.persistence; + await h.storage.collaboration.commit({ + channelId: h.plan.id, + lease: h.lease, + expectedRevision: durable.revision, + operationId: "legacy-over-reservation", + epoch: durable.committedEpoch, + sidecar: saved, + events: [], + now: new Date(), + }); + restored = await Plan.open(h.plan.id, h.backend, h.server); + let pending = structuredClone(restored.pendingCardActions); + expect(restored.conversationPlan.threads[0]?.contributions).toHaveLength(64); + restored.chat.entries.push(entry("unrelated-note", "We should update the introduction.")); + await Plan.persist(restored); + expect(restored.pendingCardActions).toEqual(pending); + await Questions.addOption(restored, h.server, restored.id, h.ws, { + kind: "question:option", + rid: "overflow", + ts: 0, + id: h.cardId, + question: Store.get(restored.questions, h.cardId)!.definition.questions[0]!.id, + key: "another-option", + label: "Yet another provider", + }); + expect(h.frames.at(-1)).toMatchObject({ ok: false, reason: "full" }); + expect(restored.pendingCardActions).toEqual(pending); + await Plan.close(restored); + restored = await Plan.open(h.plan.id, h.backend, h.server); + expect(restored.chat.entries.at(-1)?.id).toBe("unrelated-note"); + expect(restored.pendingCardActions).toEqual(pending); + } finally { + if (restored) await Plan.close(restored); + else await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/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/planner-job-interruption.ts b/apps/server/src/conversation-plan/planner-job-interruption.ts new file mode 100644 index 00000000..beb33c45 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-interruption.ts @@ -0,0 +1,47 @@ +import * as Jobs from "./jobs"; +import type { Job } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; + +/** Settle a stopped coordinator's running job before reusing its in-memory plan. */ +export async function interruptPlannerJobs( + deps: Pick< + PlannerJobDeps, + "plan" | "exclusive" | "persist" | "publishJobs" | "publishMeta" | "onError" + >, +): Promise { + let interrupted: Job | undefined; + await deps.exclusive(async () => { + let previous = deps.plan.conversationPlanJobs; + interrupted = previous.find(job => job.status === "running"); + if (!interrupted) return; + deps.plan.conversationPlanJobs = Jobs.restore(previous); + try { + await deps.persist(); + } catch (error) { + deps.plan.conversationPlanJobs = previous; + throw error; + } + }); + if (!interrupted) return false; + let report = (error: unknown) => { + try { + if (deps.onError) deps.onError(error); + else console.error("[conversation-plan] could not publish interrupted Planner job:", error); + } catch (reportError) { + console.error("[conversation-plan] Planner job error reporting failed:", reportError); + } + }; + try { + deps.publishJobs(deps.plan.conversationPlanJobs); + } catch (error) { + report(error); + } + if (interrupted.target !== "document") { + try { + deps.publishMeta(interrupted.target); + } catch (error) { + report(error); + } + } + return true; +} diff --git a/apps/server/src/conversation-plan/planner-job-support.ts b/apps/server/src/conversation-plan/planner-job-support.ts new file mode 100644 index 00000000..e85f399c --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-support.ts @@ -0,0 +1,82 @@ +import type { JobIntent } from "./effects"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; +import { proseIntent } from "./prose-job"; + +export function reason(value: unknown, fallback: string): string { + let text = value instanceof Error ? value.message : String(value ?? ""); + return text.trim().slice(0, 500) || fallback; +} + +export function outcome(value: JobOutcome): JobOutcome { + if (value?.status === "done") { + if ( + typeof value.output === "string" && value.output.length > 0 && value.output.length <= 4096 + ) { + try { + JSON.parse(value.output); + return value; + } catch {} + } + return { status: "failed", reason: "Invalid Planner job output." }; + } + if (value?.status === "failed" || value?.status === "skipped") { + return { status: value.status, reason: reason(value.reason, "Planner job failed.") }; + } + return { status: "failed", reason: "Invalid Planner job outcome." }; +} + +function title(plan: PlannerJobDeps["plan"], id: string): string { + return plan.records.get(id)?.definition.questions[0]?.question ?? "a decision"; +} + +export function currentProse(plan: PlannerJobDeps["plan"], job: Job): boolean { + let record = plan.records.get(job.target); + return !!record && proseIntent(record)?.trigger === job.trigger; +} + +export function canonicalIntent(plan: PlannerJobDeps["plan"], intent: JobIntent): JobIntent { + if (intent.kind !== "refine" && intent.kind !== "suggest") return intent; + let event = plan.conversationPlan.events.find(item => item.id === intent.trigger); + if (!event) return intent; + let thread = plan.conversationPlan.threads.find(item => + item.id === event.threadId && item.questionnaireId === intent.target + ); + let messageId = intent.kind === "refine" + && event.type === "card.linked" && event.questionnaireId === intent.target + ? thread?.questionSources[0]?.messageId + : intent.kind === "suggest" && event.type === "option.added" && thread + ? event.source?.messageId + : undefined; + if (!messageId) throw new Error("Planner job event has no matching source message"); + return { ...intent, trigger: messageId }; +} + +/** The activity copy and target for a completed job, when one is useful. */ +export function line( + plan: PlannerJobDeps["plan"], + job: Job, + output: string, +): [string, string, string?] | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch {} + let summary = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + let named = typeof summary.title === "string" && summary.title.trim() + ? summary.title.replace(/\s+/g, " ").trim().slice(0, 500) + : title(plan, job.target).replace(/\s+/g, " ").trim().slice(0, 500); + let added = typeof summary.added === "number" && Number.isSafeInteger(summary.added) + && summary.added >= 0 && summary.added <= 10 + ? summary.added + : 0; + if (job.kind === "heading") return ["Chopin drafted the title and goal", "document"]; + if (job.kind === "refine") return [`Chopin refined ${named}`, job.target, named]; + if (job.kind === "suggest" && added > 0) { + return [`Chopin suggested ${added} options for ${named}`, job.target, named]; + } + if (job.kind === "prose") return [`Chopin wrote up ${named}`, job.target, named]; + return undefined; +} diff --git a/apps/server/src/conversation-plan/planner-job-types.ts b/apps/server/src/conversation-plan/planner-job-types.ts new file mode 100644 index 00000000..38aaca99 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-job-types.ts @@ -0,0 +1,36 @@ +import type { Job, JobOutcome } from "./jobs"; +import type { Plan } from "../plan/service"; + +export type PlannerJobRunner = ( + job: Job, + prompt: string, + signal?: AbortSignal, +) => Promise; + +export type PlannerJobDeps = { + plan: + & Pick< + Plan, + | "conversationPlanJobs" + | "conversationPlanPendingEffects" + | "conversationPlanEffects" + | "records" + | "chat" + | "conversationPlan" + > + & Partial; + exclusive: (action: () => Promise) => Promise; + persist: () => Promise; + runner: PlannerJobRunner; + prompt: (job: Job) => string | undefined; + publishJobs: (jobs: Job[]) => void; + publishMeta: (questionnaireId: string) => void; + /** Called under exclusive; must commit the whole plan without acquiring the lock again. */ + activity: (text: string, questionnaireId: string, label?: string) => Promise; + /** False when the heading job's document already has prose. */ + headingAllowed?: () => boolean; + /** Wake the durable effects outbox after terminal work makes queue space available. */ + onCapacityAvailable?: () => void; + onError?: (error: unknown) => void; + now?: () => string; +}; diff --git a/apps/server/src/conversation-plan/planner-jobs-completion.test.ts b/apps/server/src/conversation-plan/planner-jobs-completion.test.ts new file mode 100644 index 00000000..35f25707 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-completion.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import type { JobOutcome } from "./jobs"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed job can be retried once, and a completed job cannot", async () => { + let attempts = 0; + let h = harness(async () => + ++attempts === 1 + ? { status: "failed", reason: "Copilot unavailable" } + : { status: "done", output: "{}" } + ); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + status: "failed", + attempts: 1, + reason: "Copilot unavailable", + }); + expect(await h.service.retry("refine:W1:m1")).toBe(true); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "done", attempts: 2 }); + expect(await h.service.retry("refine:W1:m1")).toBe(false); + expect(attempts).toBe(2); + }); + + test("closed cards skip their work without invoking the runner", async () => { + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }); + h.plan.records.get("W1")!.status = "answered"; + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(called).toBe(0); + expect(h.service.jobs()[0]).toMatchObject({ status: "skipped", attempts: 1 }); + expect(h.lines).toEqual([]); + }); + + test("the heading precondition is checked while enqueue holds the document lock", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + headingAllowed: () => false, + }); + await h.service.enqueue({ kind: "heading", target: "document", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("invalid runner output becomes a bounded failure, including malformed JSON", async () => { + let h = harness(async () => ({ status: "done", output: "not JSON" })); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + status: "failed", + reason: "Invalid Planner job output.", + }); + expect(h.lines).toEqual([]); + }); + + test("a valid JSON primitive cannot crash activity formatting", async () => { + let h = harness(async () => ({ status: "done", output: "null" })); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(h.lines).toEqual(["Chopin refined What auth system should we use?"]); + }); + + test("a storage failure at start leaves a pending job and does not spin", async () => { + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }, { failPersist: attempt => attempt === 2 }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("pending"); + expect(h.commits).toHaveLength(1); + expect(h.errors).toHaveLength(1); + expect(called).toBe(0); + h.service.wake(); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(called).toBe(1); + }); + + test("a failed activity commit restores the running job and removes its line", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + failPersist: attempt => attempt === 3, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual(["pending", "running"]); + expect(h.lines).toEqual([]); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:running"]); + expect(h.errors).toHaveLength(1); + }); + + test("stop fences a late runner result and leaves durable running work for recovery", async () => { + let finish: ((outcome: JobOutcome) => void) | undefined; + let h = harness(() => + new Promise(resolve => { + finish = resolve; + }) + ); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + for (let attempt = 0; attempt < 10; attempt++) { + if (finish) break; + await Promise.resolve(); + } + expect(finish).toBeDefined(); + h.service.stop(); + finish!({ status: "done", output: "{}" }); + await h.service.idle(); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits).toHaveLength(2); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:running"]); + expect(h.lines).toEqual([]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-failure.test.ts new file mode 100644 index 00000000..44fa0c2e --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-failure.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import * as Jobs from "./jobs"; +import { AT, harness, interrupt } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("interruption persistence failure leaves running status unpublished", async () => { + let entered = Promise.withResolvers(); + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }, { failPersist: attempt => attempt === 3 }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + await h.service.idle(); + let before = h.plan.conversationPlanJobs; + let published = h.published.length; + await expect(interrupt(h)).rejects.toThrow("storage failure 3"); + expect(h.plan.conversationPlanJobs).toBe(before); + expect(h.plan.conversationPlanJobs[0]?.status).toBe("running"); + expect(h.published).toHaveLength(published); + }); + + test("enqueue after stop rejects so an effect receipt cannot consume unqueued work", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.service.stop(); + await expect(h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" })) + .rejects.toThrow("Planner job service is stopped"); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("stop while enqueue waits for the lock does not acknowledge an uncommitted job", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let h = harness(async () => ({ status: "done", output: "{}" }), { + exclusive: async action => { + entered.resolve(); + await release.promise; + return action(); + }, + }); + let pending = h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + release.resolve(); + await expect(pending).rejects.toThrow("Planner job service is stopped"); + expect(h.service.jobs()).toEqual([]); + expect(h.commits).toEqual([]); + }); + + test("a keyed enqueue commits a coalesced intent and receipt together", async () => { + let ran: string[] = []; + let h = harness(async job => { + ran.push(job.id); + return { status: "done", output: "{}" }; + }); + h.plan.conversationPlanJobs = Jobs.enqueue([], { + kind: "refine", + target: "W1", + trigger: "m1", + }, AT); + let intent = { kind: "suggest" as const, target: "W1", trigger: "m2" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:e2", + kind: "job", + threadId: "t1", + intent, + }]; + await h.service.enqueue(intent, "job:suggest:W1:e2"); + await h.service.idle(); + expect(h.commits[0]).toMatchObject({ + jobs: [{ id: "refine:W1:m1", status: "pending" }], + pending: [], + receipts: ["job:suggest:W1:e2"], + }); + expect(ran).toEqual(["refine:W1:m1"]); + let commits = h.commits.length; + await h.service.enqueue(intent, "job:suggest:W1:e2"); + await h.service.idle(); + expect(h.commits).toHaveLength(commits); + expect(h.service.jobs()).toHaveLength(1); + expect(ran).toEqual(["refine:W1:m1"]); + }); + + test("a failed keyed commit restores the job queue, pending effect, and receipt", async () => { + let h = harness(async () => ({ status: "done", output: "{}" }), { + failPersist: attempt => attempt === 1, + }); + let intent = { kind: "refine" as const, target: "W1", trigger: "m1" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:e1", + kind: "job", + threadId: "t1", + intent, + }]; + let jobs = h.plan.conversationPlanJobs; + let pending = h.plan.conversationPlanPendingEffects; + let receipts = h.plan.conversationPlanEffects; + await expect(h.service.enqueue(intent, "job:refine:W1:e1")) + .rejects.toThrow("storage failure 1"); + expect(h.plan.conversationPlanJobs).toBe(jobs); + expect(h.plan.conversationPlanPendingEffects).toBe(pending); + expect(h.plan.conversationPlanEffects).toBe(receipts); + expect(h.published).toEqual([]); + await h.service.enqueue(intent, "job:refine:W1:e1"); + await h.service.idle(); + expect(h.commits[0]?.receipts).toEqual(["job:refine:W1:e1"]); + expect(h.service.jobs()[0]?.status).toBe("done"); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts b/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts new file mode 100644 index 00000000..43c0f8bd --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-interruption.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { type Effect, runEffects } from "./effects"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed prose enqueue stays in the closed card outbox for a later wake", async () => { + let ran = 0; + let h = harness(async () => { + ran++; + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }, { failPersist: attempt => attempt === 1 }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + let intent = { kind: "prose" as const, target: "W1", trigger: "decided:W1:1" }; + let effect: Effect = { key: "job:prose:W1:1", kind: "job", threadId: "t1", intent }; + h.plan.conversationPlanPendingEffects = [effect]; + let reported: unknown[] = []; + let deps = { + applied: (key: string) => h.plan.conversationPlanEffects.includes(key), + markApplied: async () => {}, + target: () => ({ kind: "closed" as const }), + proseReady: (thread: string, id: string, trigger: string) => + thread === "t1" && id === "W1" && trigger === intent.trigger, + insertCard: async () => "W1", + link: async () => {}, + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: h.service.enqueue, + report: (error: unknown) => reported.push(error), + }; + expect(await runEffects(deps, [effect])).toBe(0); + expect(h.plan.conversationPlanPendingEffects).toEqual([effect]); + expect(h.plan.conversationPlanEffects).toEqual([]); + expect(h.service.jobs()).toEqual([]); + expect(reported).toHaveLength(1); + expect(await runEffects(deps, [effect])).toBe(1); + await h.service.idle(); + expect(h.plan.conversationPlanPendingEffects).toEqual([]); + expect(h.plan.conversationPlanEffects).toEqual([effect.key]); + expect(h.service.jobs()[0]?.status).toBe("done"); + expect(ran).toBe(1); + }); + + test("a keyed enqueue rejects an unknown or mismatched pending effect", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + let intent = { kind: "refine" as const, target: "W1", trigger: "m1" }; + await expect(h.service.enqueue(intent, "missing")).rejects.toThrow("pending job effect"); + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:e1", + kind: "job", + intent: { + kind: "suggest", + target: "W1", + trigger: "m1", + }, + }]; + await expect(h.service.enqueue(intent, "job:suggest:W1:e1")) + .rejects.toThrow("pending job effect"); + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:e2", + kind: "job", + intent: { + kind: "refine", + target: "W1", + trigger: "different-message", + }, + }]; + await expect(h.service.enqueue(intent, "job:refine:W1:e2")) + .rejects.toThrow("pending job effect"); + expect(h.commits).toEqual([]); + }); + + test("legacy event triggers become source message IDs in durable job records", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.plan.conversationPlan.threads = [{ + id: "t1", + questionnaireId: "W1", + questionSources: [{ messageId: "question-message" }], + }]; + h.plan.conversationPlan.events = [{ + id: "card-event", + type: "card.linked", + threadId: "t1", + questionnaireId: "W1", + }, { + id: "option-event", + type: "option.added", + threadId: "t1", + source: { messageId: "option-message" }, + }]; + let refine = { kind: "refine" as const, target: "W1", trigger: "card-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:card-event", + kind: "job", + threadId: "t1", + intent: refine, + }]; + await h.service.enqueue(refine, "job:refine:W1:card-event"); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ + id: "refine:W1:question-message", + trigger: "question-message", + status: "done", + }); + let suggest = { kind: "suggest" as const, target: "W1", trigger: "option-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:suggest:W1:option-event", + kind: "job", + threadId: "t1", + intent: suggest, + }]; + await h.service.enqueue(suggest, "job:suggest:W1:option-event"); + await h.service.idle(); + expect(h.service.jobs()[1]).toMatchObject({ + id: "suggest:W1:option-message", + trigger: "option-message", + status: "done", + }); + expect(h.plan.conversationPlanEffects).toEqual([ + "job:refine:W1:card-event", + "job:suggest:W1:option-event", + ]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts new file mode 100644 index 00000000..690eddc0 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory-failure.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { memoryJobs, message } from "./planner-jobs-memory.test-fixtures"; +import { until } from "./service.test-fixtures"; + +test("a failed actual enqueue keeps its outbox effect without a receipt and retries after reopen", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + h.rejectCommits(input => + !!(input.sidecar as { conversationPlanEffects?: string[] }) + .conversationPlanEffects?.includes("job:heading:document") + ); + let { processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.errors.length > 0); + await jobs.idle(); + let held = await h.saved(); + expect(held.conversationPlanJobs).toBeUndefined(); + expect(held.conversationPlanEffects).toBeUndefined(); + expect(held.conversationPlanPendingEffects).toMatchObject([{ key: "job:heading:document" }]); + expect(h.plan.conversationPlanJobs).toEqual([]); + expect(h.publications).toEqual([]); + expect(runs).toBe(0); + h.rejectCommits(() => false); + await h.reopen(); + ({ processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "done"); + await jobs.idle(); + let settled = await h.saved(); + expect(settled.conversationPlanEffects).toEqual(["job:heading:document"]); + expect(settled.conversationPlanPendingEffects).toBeUndefined(); + expect(runs).toBe(1); + } finally { + h.rejectCommits(() => false); + await h.close(); + } +}); + +test("stopping a real held runner retains running durability until reopen records interruption", async () => { + let h = await memoryJobs(); + let entered = Promise.withResolvers(); + let runs = 0; + try { + let { processor, jobs } = h.start(async (_job, _prompt, signal) => { + runs++; + entered.resolve(); + await new Promise(resolve => + signal!.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await entered.promise; + jobs.stop(); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "running", + attempts: 0, + }]); + expect(h.publications.map(jobs => jobs[0]?.status)).toEqual(["pending", "running"]); + await h.reopen(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "failed", + reason: "interrupted", + attempts: 0, + }]); + ({ jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + jobs.wake(); + await jobs.idle(); + expect(runs).toBe(1); + expect(h.plan.chat.entries).toEqual([message]); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts b/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts new file mode 100644 index 00000000..b4a50eb1 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory.test-fixtures.ts @@ -0,0 +1,142 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { createProcessor } from "./service"; +import { createPlannerJobs, type PlannerJobRunner } from "./planner-jobs"; +import { PROMPT_FOR } from "./job-prompts"; + +function allowCommit(): boolean { + return false; +} + +async function unsupported(): Promise { + throw new Error("unexpected card effect"); +} + +export async function memoryJobs() { + let context = await openPlan(); + let plan = context.plan; + let errors: unknown[] = []; + let publications: ConversationPlan.Job[][] = []; + let jobs: ReturnType | undefined; + let processor: ReturnType | undefined; + let original = context.storage.collaboration.commit; + let reject: (input: Parameters[0]) => boolean = allowCommit; + context.backend.fatal = error => errors.push(error); + plan.persistence.fatal = context.backend.fatal; + context.storage.collaboration.commit = async input => { + if (reject(input)) throw new Error("memory commit rejected"); + return original(input); + }; + async function saved() { + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + return (loaded.sidecar ?? loaded.snapshot!.sidecar) as unknown as { + transcript: typeof plan.chat.entries; + conversationPlan: ConversationPlan.State; + conversationPlanJobs?: ConversationPlan.Job[]; + conversationPlanEffects?: string[]; + conversationPlanPendingEffects?: unknown[]; + }; + } + function start(runner: PlannerJobRunner) { + let captured = plan; + let committedJobs = structuredClone(captured.conversationPlanJobs); + let committedState = structuredClone(captured.conversationPlan); + let exclusive = (action: () => Promise) => Plan.exclusive(captured, action); + let persist = async () => { + await Plan.persistExclusive(captured); + let sidecar = await saved(); + committedJobs = sidecar.conversationPlanJobs ?? []; + committedState = sidecar.conversationPlan; + }; + jobs = createPlannerJobs({ + plan: captured, + exclusive, + persist, + runner, + prompt: job => PROMPT_FOR[job.kind]?.(captured, job), + publishJobs: current => { + expect(current).toEqual(committedJobs); + publications.push(structuredClone(current)); + }, + publishMeta: () => { + throw new Error("unexpected card job"); + }, + activity: async text => { + let before = captured.chat.entries; + captured.chat.entries = [...before, { + id: crypto.randomUUID(), + author: { kind: "system" }, + text, + ts: 2, + }]; + try { + await persist(); + } catch (error) { + captured.chat.entries = before; + throw error; + } + }, + onCapacityAvailable: () => processor?.wake(), + onError: error => errors.push(error), + }); + let coordinator = jobs; + processor = createProcessor({ + plan: captured, + exclusive, + persist, + active: () => true, + publish: state => expect(state).toEqual(committedState), + interpret: input => + interpretMessage({ + ...input, + ask: async request => mockResult(request.questions, { enough_purpose: 0.9 }), + }), + effects: { + target: () => ({ kind: "unlinked" }), + insertCard: unsupported, + link: unsupported, + addOption: unsupported, + suggest: unsupported, + prompt: unsupported, + enqueueJob: coordinator.enqueue, + report: error => errors.push(error), + }, + onError: error => errors.push(error), + }); + return { jobs: coordinator, processor }; + } + async function close() { + processor?.stop(); + jobs?.stop(); + await jobs?.idle(); + await Plan.close(plan); + } + return { + get plan() { + return plan; + }, + start, + saved, + publications, + errors, + rejectCommits(check: typeof reject) { + reject = check; + }, + async reopen() { + await close(); + plan = await Plan.open(context.channel.id, context.backend, context.server); + }, + close, + }; +} + +export let message = { + id: "purpose", + author: { kind: "member" as const, handle: "ana" }, + text: "We need to figure out our authentication approach.", + ts: 1, +}; diff --git a/apps/server/src/conversation-plan/planner-jobs-memory.test.ts b/apps/server/src/conversation-plan/planner-jobs-memory.test.ts new file mode 100644 index 00000000..2aed65a8 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-memory.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { memoryJobs, message } from "./planner-jobs-memory.test-fixtures"; +import { until } from "./service.test-fixtures"; + +test("real processor commits a heading receipt and running job before Planner execution", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + let { processor, jobs } = h.start(async (job, prompt) => { + runs++; + let saved = await h.saved(); + expect(saved.conversationPlanEffects).toEqual(["job:heading:document"]); + expect(saved.conversationPlanPendingEffects).toBeUndefined(); + expect(saved.conversationPlanJobs).toMatchObject([{ id: job.id, status: "running" }]); + expect(saved.transcript).toEqual([message]); + expect(prompt).toContain("[Background job: heading]"); + return { status: "done", output: "{}" }; + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "done"); + await jobs.idle(); + let completed = await h.saved(); + expect(completed.conversationPlanJobs).toMatchObject([{ status: "done", attempts: 1 }]); + expect(completed.transcript.map(entry => entry.text)).toEqual([ + message.text, + "Chopin drafted the title and goal", + ]); + expect(h.publications.map(jobs => jobs[0]?.status)).toEqual(["pending", "running", "done"]); + await h.reopen(); + ({ processor, jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + processor.afterMessage(); + jobs.wake(); + await jobs.idle(); + expect(h.plan.conversationPlanJobs[0]?.status).toBe("done"); + expect(runs).toBe(1); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); + +test("a real persisted Planner failure reopens as failed and only explicit retry runs it", async () => { + let h = await memoryJobs(); + let runs = 0; + try { + let { processor, jobs } = h.start(async () => { + runs++; + throw new Error("runner unavailable"); + }); + await processor.accept(message); + processor.afterMessage(); + await until(() => h.plan.conversationPlanJobs[0]?.status === "failed"); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ + status: "failed", + attempts: 1, + }]); + await h.reopen(); + ({ jobs } = h.start(async () => { + runs++; + return { status: "done", output: "{}" }; + })); + jobs.wake(); + await jobs.idle(); + expect(runs).toBe(1); + expect(await jobs.retry(h.plan.conversationPlanJobs[0]!.id)).toBe(true); + await jobs.idle(); + expect((await h.saved()).conversationPlanJobs).toMatchObject([{ status: "done", attempts: 2 }]); + expect(runs).toBe(2); + expect(h.errors).toEqual([]); + } finally { + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts b/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts new file mode 100644 index 00000000..f9110e56 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-receipt-failure.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { type Effect, runEffects } from "./effects"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a failed option receipt blocks its same-thread job until the option replay succeeds", async () => { + let ran = 0; + let h = harness(async () => { + ran++; + return { status: "done", output: "{}" }; + }); + let receipts = new Set(); + let marked = 0; + let added: string[] = []; + let reported: unknown[] = []; + let effects: Effect[] = [ + { + key: "job:refine:W1:e1", + kind: "job", + threadId: "t1", + intent: { kind: "refine", target: "W1", trigger: "m1" }, + }, + { + key: "option:o1", + kind: "add-option", + threadId: "t1", + optionId: "o1", + label: "GitHub Apps", + trigger: "m2", + }, + ]; + h.plan.conversationPlanPendingEffects = [effects[0]!]; + let deps = { + applied: (key: string) => receipts.has(key), + markApplied: async (key: string) => { + if (++marked === 1) throw new Error("receipt lost"); + receipts.add(key); + }, + target: () => ({ kind: "open" as const, id: "W1" }), + insertCard: async () => "W1", + link: async () => {}, + addOption: async (_id: string, input: { optionId: string }) => { + if (!added.includes(input.optionId)) added.push(input.optionId); + }, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: h.service.enqueue, + report: (error: unknown) => reported.push(error), + }; + expect(await runEffects(deps, effects)).toBe(0); + expect(added).toEqual(["o1"]); + await h.service.idle(); + expect(await runEffects(deps, effects)).toBe(2); + await h.service.idle(); + expect(added).toEqual(["o1"]); + expect(ran).toBe(1); + expect(h.service.jobs()).toHaveLength(1); + expect(reported).toHaveLength(1); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts b/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts new file mode 100644 index 00000000..62d1960e --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-receipt.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import type { JobOutcome } from "./jobs"; +import { harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("a legacy event without a matching source keeps its effect pending", async () => { + let h = harness(async () => ({ status: "done", output: "{}" })); + h.plan.conversationPlan.events = [{ + id: "card-event", + type: "card.linked", + threadId: "t1", + questionnaireId: "W1", + }]; + let intent = { kind: "refine" as const, target: "W1", trigger: "card-event" }; + h.plan.conversationPlanPendingEffects = [{ + key: "job:refine:W1:card-event", + kind: "job", + threadId: "t1", + intent, + }]; + await expect(h.service.enqueue(intent, "job:refine:W1:card-event")) + .rejects.toThrow("source message"); + expect(h.service.jobs()).toEqual([]); + expect(h.plan.conversationPlanPendingEffects).toHaveLength(1); + expect(h.plan.conversationPlanEffects).toEqual([]); + }); + + test("stop during the running commit fences the runner and its publication", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let called = 0; + let h = harness(async () => { + called++; + return { status: "done", output: "{}" }; + }, { + waitPersist: async attempt => { + if (attempt !== 2) return; + entered.resolve(); + await release.promise; + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + release.resolve(); + await h.service.idle(); + expect(called).toBe(0); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual(["pending", "running"]); + expect(h.published).toEqual([["refine:W1:m1:pending"]]); + }); + + test("a wake during active work drains newly enqueued work before idle", async () => { + let finish: ((outcome: JobOutcome) => void) | undefined; + let seen: string[] = []; + let h = harness(job => { + seen.push(job.id); + return seen.length === 1 + ? new Promise(resolve => { + finish = resolve; + }) + : Promise.resolve({ status: "done", output: "{}" }); + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + for (let attempt = 0; attempt < 10; attempt++) { + if (finish) break; + await Promise.resolve(); + } + expect(finish).toBeDefined(); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + finish!({ status: "done", output: "{}" }); + await h.service.idle(); + expect(seen).toEqual(["refine:W1:m1", "suggest:W1:m2"]); + expect(h.service.jobs().map(job => job.status)).toEqual(["done", "done"]); + }); + + test("a durable completion wakes the effects outbox to use freed capacity", async () => { + let observed: string[] = []; + let h = harness(async () => ({ status: "done", output: "{}" }), { + onCapacityAvailable: () => { + observed.push(h.commits.at(-1)?.jobs[0]?.status ?? "missing"); + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(observed).toEqual(["done"]); + }); + + test("a durable skip wakes the effects outbox to reclaim terminal capacity", async () => { + let observed: string[] = []; + let h = harness(async () => ({ status: "skipped", reason: "AGENT=off" }), { + onCapacityAvailable: () => { + observed.push(h.commits.at(-1)?.jobs[0]?.status ?? "missing"); + }, + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(observed).toEqual(["skipped"]); + }); + + test("thrown runner errors become bounded, retryable failure reasons", async () => { + let h = harness(async () => { + throw new Error("x".repeat(1000)); + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "failed", attempts: 1 }); + expect(h.service.jobs()[0]?.reason).toHaveLength(500); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs-retry.test.ts b/apps/server/src/conversation-plan/planner-jobs-retry.test.ts new file mode 100644 index 00000000..209219be --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs-retry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { createPlannerJobs } from "./planner-jobs"; +import { AT, harness, interrupt } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("stop aborts a held scripted runner while retaining its durable running status", async () => { + let entered = Promise.withResolvers(); + let supplied = false; + let aborted = false; + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + if (!signal) throw new Error("runner signal is missing"); + supplied = true; + await new Promise(resolve => + signal.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + aborted = signal.aborted; + return { status: "skipped", reason: "stopped" }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + h.service.stop(); + await h.service.idle(); + expect(supplied).toBe(true); + expect(aborted).toBe(true); + expect(h.service.jobs()[0]?.status).toBe("running"); + expect(h.commits).toHaveLength(2); + }); + + test("in-memory restart durably interrupts running work and drains later pending work", async () => { + let entered = Promise.withResolvers(); + let called: string[] = []; + let h = harness(async (job, _prompt, signal) => { + called.push(job.id); + if (called.length > 1) return { status: "done", output: "{}" }; + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await entered.promise; + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + h.service.stop(); + await h.service.idle(); + expect(h.service.jobs().map(job => job.status)).toEqual(["running", "pending"]); + expect(await interrupt(h)).toBe(true); + expect(h.commits.at(-1)?.jobs).toMatchObject([ + { status: "failed", reason: "interrupted" }, + { status: "pending" }, + ]); + expect(h.published.at(-1)).toEqual([ + "refine:W1:m1:failed", + "suggest:W1:m2:pending", + ]); + expect(h.publishedAfter.at(-1)).toBe(h.commits.length); + let restarted = createPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + runner: async job => { + called.push(job.id); + return { status: "done", output: "{}" }; + }, + prompt: () => "prompt", + publishJobs: () => {}, + publishMeta: () => {}, + activity: h.persist, + now: () => AT, + }); + restarted.wake(); + await restarted.idle(); + expect(called).toEqual(["refine:W1:m1", "suggest:W1:m2"]); + expect(h.plan.conversationPlanJobs.map(job => job.status)).toEqual(["failed", "done"]); + }); + + test("an interrupted old prose generation does not replay after restart", async () => { + let entered = Promise.withResolvers(); + let h = harness(async (_job, _prompt, signal) => { + entered.resolve(); + await new Promise(resolve => + signal?.addEventListener("abort", () => resolve(), { once: true }) + ); + return { status: "failed", reason: "stopped" }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await entered.promise; + let previous = h.plan.records.get("W1")!; + h.plan.records.set("W1", { ...previous, history: [{}] } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:2" }); + h.service.stop(); + await h.service.idle(); + expect(await interrupt(h)).toBe(true); + let ran: string[] = []; + let restarted = createPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + runner: async job => { + ran.push(job.trigger); + return { status: "done", output: "{}" }; + }, + prompt: () => "prompt", + publishJobs: () => {}, + publishMeta: () => {}, + activity: h.persist, + now: () => AT, + }); + restarted.wake(); + await restarted.idle(); + expect(h.service.jobs().map(job => [job.status, job.reason])).toEqual([ + ["failed", "interrupted"], + ["done", undefined], + ]); + expect(ran).toEqual(["decided:W1:2"]); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts b/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts new file mode 100644 index 00000000..666abbd4 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.test-fixtures.ts @@ -0,0 +1,88 @@ +import type { Effect } from "./effects"; +import { createPlannerJobs, interruptPlannerJobs } from "./planner-jobs"; +import type { Job, JobOutcome } from "./jobs"; + +export const AT = "2026-09-25T10:00:00.000Z"; + +export function harness( + runner: (job: Job, prompt: string, signal?: AbortSignal) => Promise, + options: { + failPersist?: (attempt: number) => boolean; + waitPersist?: (attempt: number) => Promise; + headingAllowed?: () => boolean; + onCapacityAvailable?: () => void; + exclusive?: (action: () => Promise) => Promise; + } = {}, +) { + let commits: Array<{ jobs: Job[]; lines: string[]; pending: Effect[]; receipts: string[] }> = []; + let published: string[][] = []; + let publishedAfter: number[] = []; + let meta: string[] = []; + let lines: string[] = []; + let errors: unknown[] = []; + let persistAttempts = 0; + let plan = { + conversationPlanJobs: [] as Job[], + conversationPlanPendingEffects: [] as Effect[], + conversationPlanEffects: [] as string[], + records: new Map([["W1", { + status: "open", + definition: { questions: [{ question: "What auth system should we use?", options: [] }] }, + }]]), + chat: { entries: [] }, + conversationPlan: { threads: [] as unknown[], events: [] as unknown[] }, + }; + let persist = async () => { + persistAttempts++; + await options.waitPersist?.(persistAttempts); + if (options.failPersist?.(persistAttempts)) { + throw new Error(`storage failure ${persistAttempts}`); + } + commits.push({ + jobs: structuredClone(plan.conversationPlanJobs), + lines: [...lines], + pending: structuredClone(plan.conversationPlanPendingEffects), + receipts: [...plan.conversationPlanEffects], + }); + }; + let service = createPlannerJobs({ + plan: plan as never, + exclusive: options.exclusive ?? (async action => action()), + persist, + runner, + prompt: () => "prompt", + publishJobs: jobs => { + published.push(jobs.map(job => `${job.id}:${job.status}`)); + publishedAfter.push(commits.length); + }, + publishMeta: id => meta.push(id), + activity: async text => { + lines.push(text); + try { + await persist(); + } catch (error) { + lines.pop(); + throw error; + } + }, + onError: error => errors.push(error), + headingAllowed: options.headingAllowed, + onCapacityAvailable: options.onCapacityAvailable, + now: () => AT, + }); + return { plan, service, persist, commits, published, publishedAfter, meta, lines, errors }; +} + +export function interrupt(h: ReturnType): Promise { + return interruptPlannerJobs({ + plan: h.plan as never, + exclusive: async action => action(), + persist: h.persist, + publishJobs: jobs => { + h.published.push(jobs.map(job => `${job.id}:${job.status}`)); + h.publishedAfter.push(h.commits.length); + }, + publishMeta: id => h.meta.push(id), + onError: error => h.errors.push(error), + }); +} diff --git a/apps/server/src/conversation-plan/planner-jobs.test.ts b/apps/server/src/conversation-plan/planner-jobs.test.ts new file mode 100644 index 00000000..3a6d621a --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import * as Jobs from "./jobs"; +import type { Job } from "./jobs"; +import { AT, harness } from "./planner-jobs.test-fixtures"; + +describe("createPlannerJobs", () => { + test("posts one durable prose activity line after a completed write", async () => { + let h = harness(async () => ({ + status: "done", + output: JSON.stringify({ title: "Which authentication approach?", mode: "insert" }), + })); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Which authentication approach?", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await h.service.idle(); + expect(h.lines).toEqual(["Chopin wrote up Which authentication approach?"]); + expect(h.commits.at(-1)?.lines).toEqual(h.lines); + }); + + test("a queued old prose generation skips before prompting or invoking the runner", async () => { + let ran: string[] = []; + let h = harness(async job => { + ran.push(job.trigger); + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "cy", + decidedAt: 1, + history: [{ choices: [], owner: "ana", at: 1 }], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + h.plan.conversationPlanJobs = Jobs.enqueue( + Jobs.enqueue([], { kind: "prose", target: "W1", trigger: "decided:W1:1" }, AT), + { kind: "prose", target: "W1", trigger: "decided:W1:2" }, + AT, + ); + h.service.wake(); + await h.service.idle(); + expect(h.plan.conversationPlanJobs.map(job => job.status)).toEqual(["skipped", "done"]); + expect(ran).toEqual(["decided:W1:2"]); + }); + + test("a prose turn superseded by a new decision does not announce an old success", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let h = harness(async () => { + entered.resolve(); + await release.promise; + return { status: "done", output: JSON.stringify({ title: "Decision" }) }; + }); + h.plan.records.set("W1", { + id: "W1", + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "ana", + decidedAt: 1, + history: [], + definition: { questions: [{ question: "Decision", options: [] }] }, + } as never); + await h.service.enqueue({ kind: "prose", target: "W1", trigger: "decided:W1:1" }); + await entered.promise; + let previous = h.plan.records.get("W1")!; + h.plan.records.set("W1", { ...previous, history: [{}] } as never); + release.resolve(); + await h.service.idle(); + expect(h.service.jobs()[0]).toMatchObject({ status: "skipped" }); + expect(h.lines).toEqual([]); + }); + + test("persists running before the runner and completes with one durable activity line", async () => { + let seen: Array<{ job: Job; committed: string }> = []; + let h = harness(async job => { + seen.push({ job, committed: h.commits.at(-1)!.jobs[0]!.status }); + return { + status: "done", + output: JSON.stringify({ title: "What auth system should we use?", added: 1 }), + }; + }); + await h.service.enqueue({ kind: "refine", target: "W1", trigger: "m1" }); + await h.service.idle(); + expect(seen).toMatchObject([{ + job: { id: "refine:W1:m1", status: "running" }, + committed: "running", + }]); + expect(h.commits.map(commit => commit.jobs[0]?.status)).toEqual([ + "pending", + "running", + "done", + ]); + expect(h.commits.at(-1)?.lines).toEqual([ + "Chopin refined What auth system should we use?", + ]); + expect(h.published.at(-1)).toEqual(["refine:W1:m1:done"]); + expect(h.publishedAfter).toEqual([1, 2, 3]); + expect(h.meta).toEqual(["W1", "W1", "W1"]); + expect(h.errors).toEqual([]); + }); + + test("suggestion activity appears only when an option was added", async () => { + let added = [0, 2]; + let h = harness(async () => ({ + status: "done", + output: JSON.stringify({ title: "Authentication?", added: added.shift() }), + })); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m1" }); + await h.service.idle(); + await h.service.enqueue({ kind: "suggest", target: "W1", trigger: "m2" }); + await h.service.idle(); + expect(h.lines).toEqual(["Chopin suggested 2 options for Authentication?"]); + expect(h.commits).toHaveLength(6); + }); +}); diff --git a/apps/server/src/conversation-plan/planner-jobs.ts b/apps/server/src/conversation-plan/planner-jobs.ts new file mode 100644 index 00000000..949fefa5 --- /dev/null +++ b/apps/server/src/conversation-plan/planner-jobs.ts @@ -0,0 +1,231 @@ +/** Runs one document's durable Planner jobs in order. */ + +import * as Jobs from "./jobs"; +import { type JobIntent, MAX_EFFECTS } from "./effects"; +import type { Job, JobOutcome } from "./jobs"; +import type { PlannerJobDeps } from "./planner-job-types"; +import { canonicalIntent, currentProse, line, outcome, reason } from "./planner-job-support"; + +export { interruptPlannerJobs } from "./planner-job-interruption"; +export { line } from "./planner-job-support"; +export type { PlannerJobDeps, PlannerJobRunner } from "./planner-job-types"; + +export function createPlannerJobs(deps: PlannerJobDeps) { + let { plan } = deps; + let now = deps.now ?? (() => new Date().toISOString()); + let stopped = false; + let controller = new AbortController(); + let draining: Promise | undefined; + let wakeRequested = false; + + function report(error: unknown): void { + try { + if (deps.onError) deps.onError(error); + else console.error("[conversation-plan] Planner job failed:", error); + } catch (reportError) { + console.error("[conversation-plan] Planner job error reporting failed:", reportError); + } + } + + async function change( + next: (jobs: Job[]) => Job[], + commit: () => Promise = deps.persist, + ): Promise { + return deps.exclusive(async () => { + if (stopped) return undefined; + let previous = plan.conversationPlanJobs; + let updated = next(previous); + if (updated === previous) return undefined; + plan.conversationPlanJobs = updated; + try { + await commit(); + } catch (error) { + plan.conversationPlanJobs = previous; + throw error; + } + return updated; + }); + } + + async function enqueueWithReceipt(intent: JobIntent, key: string): Promise { + return deps.exclusive(async () => { + let receipts = plan.conversationPlanEffects; + if (receipts.includes(key)) return undefined; + if (stopped) throw new Error("Planner job service is stopped"); + let pending = plan.conversationPlanPendingEffects; + let effect = pending.find(item => item.key === key); + if ( + effect?.kind !== "job" || effect.intent.kind !== intent.kind + || effect.intent.target !== intent.target + || effect.intent.trigger !== intent.trigger + ) throw new Error("Planner job receipt has no matching pending job effect"); + + let previous = plan.conversationPlanJobs; + let canonical = canonicalIntent(plan, intent); + let updated = intent.kind === "heading" + && (previous.some(job => job.kind === "heading") || deps.headingAllowed?.() === false) + ? previous + : Jobs.enqueue(previous, canonical, now()); + plan.conversationPlanJobs = updated; + plan.conversationPlanPendingEffects = pending.filter(item => item.key !== key); + plan.conversationPlanEffects = [...receipts, key].slice(-MAX_EFFECTS); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanJobs = previous; + plan.conversationPlanPendingEffects = pending; + plan.conversationPlanEffects = receipts; + throw error; + } + return updated === previous + ? undefined + : updated.find(job => job.id === Jobs.jobId(canonical)); + }); + } + + function announce(job: Job | undefined): void { + if (stopped) return; + try { + deps.publishJobs(plan.conversationPlanJobs); + } catch (error) { + report(error); + } + if (job && job.target !== "document") { + try { + deps.publishMeta(job.target); + } catch (error) { + report(error); + } + } + } + + async function run(job: Job): Promise { + if (job.kind === "heading" && deps.headingAllowed?.() === false) { + return { status: "skipped", reason: "The document already has prose." }; + } + if (job.kind === "refine" || job.kind === "suggest") { + let status = plan.records.get(job.target)?.status; + if (status !== "open" && status !== "reopened") { + return { status: "skipped", reason: "The decision card is no longer open." }; + } + } + if (job.kind === "prose") { + if (!currentProse(plan, job)) { + return { status: "skipped", reason: "The saved decision has changed." }; + } + } + try { + let prompt = deps.prompt(job); + if (prompt === undefined) { + return { status: "failed", reason: `no prompt for ${job.kind} jobs` }; + } + let result = outcome(await deps.runner(job, prompt, controller.signal)); + return job.kind === "prose" && !currentProse(plan, job) + ? { status: "skipped", reason: "The saved decision has changed." } + : result; + } catch (error) { + return { status: "failed", reason: reason(error, "The Planner could not be reached.") }; + } + } + + async function drain(): Promise { + for (;;) { + if (stopped) return; + let pending = Jobs.next(plan.conversationPlanJobs); + if (!pending) return; + let started = await change(jobs => Jobs.start(jobs, pending.id, now())); + if (stopped) return; + if (!started) continue; + let running = started.find(job => job.id === pending.id); + if (!running) throw new Error("started Planner job is missing"); + announce(running); + let result = await run(running); + if (stopped) return; + let finalResult = result; + let activity = result.status === "done" ? line(plan, running, result.output) : undefined; + let settled = await change( + jobs => { + if (running.kind === "prose" && !currentProse(plan, running)) { + finalResult = { status: "skipped", reason: "The saved decision has changed." }; + activity = undefined; + } + return Jobs.settle(jobs, running.id, finalResult, now()); + }, + () => + activity + ? deps.activity(activity[0], activity[1], activity[2]) + : deps.persist(), + ); + if (!settled) continue; + announce(settled.find(job => job.id === running.id)); + if (!stopped && (finalResult.status === "done" || finalResult.status === "skipped")) { + try { + deps.onCapacityAvailable?.(); + } catch (error) { + report(error); + } + } + } + } + + function wake(): void { + if (stopped) return; + if (draining) { + wakeRequested = true; + return; + } + let failed = false; + draining = drain().catch(error => { + failed = true; + report(error); + }).finally(() => { + draining = undefined; + let requested = wakeRequested; + wakeRequested = false; + if (!failed && requested && !stopped && Jobs.next(plan.conversationPlanJobs)) wake(); + }); + } + + return { + async enqueue(intent: JobIntent, effectKey?: string): Promise { + if (effectKey !== undefined) { + let changed = await enqueueWithReceipt(intent, effectKey); + if (changed) announce(changed); + wake(); + return; + } + if (stopped) throw new Error("Planner job service is stopped"); + let changed = await change(jobs => { + if ( + intent.kind === "heading" + && (jobs.some(job => job.kind === "heading") || deps.headingAllowed?.() === false) + ) return jobs; + return Jobs.enqueue(jobs, intent, now()); + }); + if (stopped && !changed) throw new Error("Planner job service is stopped"); + if (changed) announce(changed.find(job => job.id === Jobs.jobId(intent))); + wake(); + }, + async retry(jobId: string): Promise { + let changed = await change(jobs => Jobs.retry(jobs, jobId, now())); + if (!changed) return false; + announce(changed.find(job => job.id === jobId)); + wake(); + return true; + }, + wake, + async idle(): Promise { + let task = draining; + while (task) { + await task; + task = draining; + } + }, + stop(): void { + stopped = true; + controller.abort(); + wakeRequested = false; + }, + jobs: () => plan.conversationPlanJobs, + }; +} diff --git a/apps/server/src/conversation-plan/processor-card-context.ts b/apps/server/src/conversation-plan/processor-card-context.ts new file mode 100644 index 00000000..58807f66 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-card-context.ts @@ -0,0 +1,33 @@ +import type { LinkedCardOptions } from "./questions"; +import type { Dependencies, State } from "./processor-types"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function cardCycle(plan: Dependencies["plan"], thread: State["threads"][number]) { + let id = thread.questionnaireId; + let record = id ? plan.records.get(id) : undefined; + return { id, status: record?.status, generation: record?.history.length }; +} + +export function linkedCardOptions(plan: Dependencies["plan"], state: State): LinkedCardOptions { + let cards = new Map }>(); + for (let thread of state.threads) { + let cardId = thread.questionnaireId; + let record = cardId && plan.records.get(cardId); + if ( + !cardId || !record || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + ) continue; + let options = record.definition.questions[0]?.options; + if ( + !options || options.length > 10 + || new Set(options.map(item => item.id)).size !== options.length + ) { + continue; + } + cards.set(thread.id, { + cardId, + options: options.map(item => ({ id: item.id, label: item.label })), + }); + } + return cards; +} diff --git a/apps/server/src/conversation-plan/processor-commands.ts b/apps/server/src/conversation-plan/processor-commands.ts new file mode 100644 index 00000000..ed58394f --- /dev/null +++ b/apps/server/src/conversation-plan/processor-commands.ts @@ -0,0 +1,185 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member, State } from "./processor-types"; +import { applyCorrection, enqueue, retryMessage } from "./domain"; +import { effectsFor } from "./effects"; +import { assertOptionCapacity } from "./option-capacity"; +import { applyEvent } from "./events"; +import { assertStateShape, MAX_ANALYSIS, MAX_QUEUE } from "./validation"; +import { QUESTION_SET_VERSION } from "./question-shared"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createCommands( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, + wake: () => void, +) { + async function accept(message: Chat.Entry): Promise { + await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let index = plan.chat.entries.length; + plan.chat.entries.push(message); + try { + if (previous.queue.length < MAX_QUEUE) { + plan.conversationPlan = enqueue(previous, message.id); + } else { + let skipped: ConversationPlan.AnalysisRecord = { + messageId: message.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "unavailable", + status: "unlinked", + passes: [], + policyGate: "queue full; skipped", + eventIds: [], + }; + let next = { + ...previous, + revision: previous.revision + 1, + analysis: [...previous.analysis, skipped].slice(-MAX_ANALYSIS), + }; + assertStateShape(next); + plan.conversationPlan = next; + } + await deps.persist(); + } catch (error) { + plan.conversationPlan = previous; + plan.chat.entries.splice(index, 1); + throw error; + } + }); + } + + function afterMessage(): void { + publish(); + wake(); + } + + async function correct(action: ConversationPlan.CorrectionAction, actor: Member) { + let changed = false; + let state = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let pending = plan.conversationPlanPendingEffects; + let next = applyCorrection(previous, action, actor, Date.now(), plan.chat.entries); + if (next === previous) return previous; + if (action.change.kind === "add-excerpt") { + let thread = next.threads.find(item => item.id === action.threadId); + let record = thread?.questionnaireId && plan.records.get(thread.questionnaireId); + if (!record || !["open", "reopened"].includes(record.status)) { + throw new Error("The decision card is no longer open"); + } + } + plan.conversationPlan = next; + try { + let accepted = next.events.at(-1)!; + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor([accepted], next, undefined, plan.records), + ); + assertOptionCapacity(plan); + await deps.persist(); + changed = true; + return next; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = pending; + throw error; + } + }); + if (changed) { + publish(); + wake(); + } + return { eventId: `human:${actor.handle}:${action.actionId}`, revision: state.revision }; + } + + async function record( + make: ConversationPlan.Event | ((state: State) => ConversationPlan.Event | undefined), + pendingCardActionId?: string, + ) { + let changed = false; + let consumed = false; + await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + let previous = plan.conversationPlan; + let pending = plan.conversationPlanPendingEffects; + let actions = plan.pendingCardActions; + if (pendingCardActionId && actions[0]?.id !== pendingCardActionId) { + if (previous.events.some(event => event.id === pendingCardActionId)) return; + throw new Error("card action is not at the FIFO head"); + } + let event = typeof make === "function" ? make(previous) : make; + if (pendingCardActionId && event && event.id !== pendingCardActionId) { + throw new Error("card action ID does not match mirror event"); + } + let next = event ? applyEvent(previous, event) : previous; + if (next === previous && !pendingCardActionId) return; + plan.conversationPlan = next; + if (pendingCardActionId) plan.pendingCardActions = actions.slice(1); + try { + if (next !== previous && event) { + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor([event], next, undefined, plan.records), + ); + } + await deps.persist(); + changed = next !== previous; + consumed = !!pendingCardActionId; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = pending; + plan.pendingCardActions = actions; + throw error; + } + }); + if (changed) publish(); + if (changed || consumed) wake(); + return changed; + } + + async function retry(actionId: string, messageId: string, actor: Member) { + let changed = false; + let queued = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + actor.kind !== "member" || !actor.handle + || typeof actionId !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(actionId) + || typeof messageId !== "string" || !messageId || messageId.length > 200 + ) throw new Error("invalid analysis retry"); + let id = `human:${actor.handle}:${actionId}`; + let existing = plan.conversationPlanRetries.find(item => item.id === id); + if (existing) { + if (existing.messageId !== messageId) throw new Error("analysis retry ID collision"); + return plan.conversationPlan.queue.some(item => + item.messageId === messageId && item.status === "pending" + ); + } + if (plan.conversationPlanRetries.length >= 4096) { + throw new Error("analysis retry history is full"); + } + let previous = plan.conversationPlan; + let retries = plan.conversationPlanRetries; + plan.conversationPlan = retryMessage(previous, messageId); + plan.conversationPlanRetries = [...retries, { id, messageId }]; + try { + await deps.persist(); + changed = true; + return true; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanRetries = retries; + throw error; + } + }); + if (changed) { + publish(); + wake(); + } + return { messageId, queued }; + } + return { accept, afterMessage, correct, record, retry }; +} diff --git a/apps/server/src/conversation-plan/processor-fields.ts b/apps/server/src/conversation-plan/processor-fields.ts new file mode 100644 index 00000000..3442eee1 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-fields.ts @@ -0,0 +1,48 @@ +import { isDeepStrictEqual } from "node:util"; +import { type Effect, MAX_EFFECTS } from "./effects"; +import { QUESTION_SET_VERSION } from "./question-shared"; +import type { Analysis, Dependencies, State } from "./processor-types"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export const MAX_PENDING_EFFECTS = MAX_EFFECTS; + +export function appendEffects(plan: Dependencies["plan"], effects: readonly Effect[]): Effect[] { + let pending = [...plan.conversationPlanPendingEffects]; + for (let effect of effects) { + if (plan.conversationPlanEffects.includes(effect.key)) continue; + let existing = pending.find(item => item.key === effect.key); + if (existing) { + if ( + effect.key === "job:heading:document" + && effect.kind === "job" && existing.kind === "job" + && effect.intent.kind === "heading" && existing.intent.kind === "heading" + ) continue; + if (!isDeepStrictEqual(existing, effect)) { + throw new Error("conversation effect key collision"); + } + continue; + } + if (pending.length >= MAX_PENDING_EFFECTS) { + throw new Error("conversation effect outbox is full"); + } + pending.push(effect); + } + return pending; +} + +export function failure(reason: string): Analysis { + return { + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "unavailable", + status: "failed", + passes: [], + error: reason, + }; +} + +export function sameThreads(before: State, now: State): boolean { + return before.threads.length === now.threads.length + && before.threads.every((thread, index) => + thread.id === now.threads[index]?.id && thread.version === now.threads[index]?.version + ); +} diff --git a/apps/server/src/conversation-plan/processor-outbox.ts b/apps/server/src/conversation-plan/processor-outbox.ts new file mode 100644 index 00000000..20091de9 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-outbox.ts @@ -0,0 +1,99 @@ +import { effectsFor, MAX_EFFECTS, recoverable, runEffects } from "./effects"; +import type { Dependencies, EffectCommands } from "./processor-types"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createOutbox( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + wake: () => void, + effectCommands: EffectCommands | undefined, +) { + let recovered = false; + async function markApplied(key: string): Promise { + await deps.exclusive(async () => { + if (!plan.conversationPlanPendingEffects.some(item => item.key === key)) return; + let pending = plan.conversationPlanPendingEffects; + let receipts = plan.conversationPlanEffects; + plan.conversationPlanPendingEffects = pending.filter(item => item.key !== key); + plan.conversationPlanEffects = [...receipts, key].slice(-MAX_EFFECTS); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanPendingEffects = pending; + plan.conversationPlanEffects = receipts; + throw error; + } + }); + } + + async function recover(): Promise { + if (recovered) return; + await deps.exclusive(async () => { + if (!active()) return; + let heldThreads = new Set( + plan.pendingCardActions.filter(action => + ["decided", "reopened", "discarded"].includes(action.kind) + ).map(action => action.threadId), + ); + let historical = plan.conversationPlan.events.filter(event => + !heldThreads.has(event.threadId) + && (event.type === "settle.suggested" || event.type === "settle.agreed" + || event.type === "settle.deferred" || event.type === "settle.resumed" + || event.type === "stance.changed") + ); + let advisory = effectsFor(historical, plan.conversationPlan, undefined, plan.records) + .filter(effect => + effect.kind === "suggest" || effect.kind === "prompt" + || effect.kind === "defer-prompt" + ); + let missing = [ + ...effectsFor( + recoverable(plan.conversationPlan), + plan.conversationPlan, + undefined, + plan.records, + ), + ...advisory, + ] + .filter(effect => + !plan.conversationPlanPendingEffects.some(item => item.key === effect.key) + && !plan.conversationPlanEffects.includes(effect.key) + ); + if (missing.length === 0) return; + let previous = plan.conversationPlanPendingEffects; + plan.conversationPlanPendingEffects = appendEffects(plan, missing); + try { + await deps.persist(); + } catch (error) { + plan.conversationPlanPendingEffects = previous; + throw error; + } + }); + recovered = true; + } + + async function drainEffects(): Promise { + while (active()) { + let commands = effectCommands; + if (!commands) return; + let snapshot = await deps.exclusive(async () => + structuredClone(plan.conversationPlanPendingEffects) + ); + if (snapshot.length === 0) return; + let completed = await runEffects({ + ...commands, + applied: key => plan.conversationPlanEffects.includes(key), + markApplied, + }, snapshot); + if (completed === 0) return; + } + } + + function setEffects(commands: EffectCommands): void { + effectCommands = commands; + wake(); + } + return { markApplied, recover, drainEffects, setEffects }; +} diff --git a/apps/server/src/conversation-plan/processor-research-admission.ts b/apps/server/src/conversation-plan/processor-research-admission.ts new file mode 100644 index 00000000..6063fd47 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-admission.ts @@ -0,0 +1,125 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { createHash } from "node:crypto"; +import { offerResearch } from "./domain"; +import { renderResearchTask } from "./validation"; +import type { Interpretation } from "./interpret"; +import type { State } from "./processor-types"; +import { researchQuoteFocus, researchQuoteSupportsPair } from "./processor-research-quotes"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +function researchId(kind: string, parts: readonly string[]): string { + let hash = createHash("sha256").update(JSON.stringify(parts)).digest("hex"); + return `research:${kind}:${hash}`; +} + +export function admitResearchOffer( + before: State, + next: State, + message: Chat.Entry, + candidate: Interpretation["researchOffer"], + card: { cardId: string; options: ReadonlyArray<{ id: string; label: string }> } | undefined, + cardRecordExists: boolean, +): State { + if (!candidate || message.author.kind !== "member") return next; + let prior = before.threads.find(item => item.id === candidate.threadId); + let thread = next.threads.find(item => item.id === candidate.threadId); + if (!prior || !thread || !["exploring", "leaning", "reopened"].includes(thread.status)) { + return next; + } + let isConcern = candidate.kind === "current-cost-concern"; + let kind = isConcern ? "current-cost-concern" : "current-cost-comparison"; + let currentOptions = thread.contributions.filter(item => item.kind === "option"); + let priorOptions = prior.contributions.filter(item => item.kind === "option"); + if ( + isConcern + ? ![3, 4].includes(currentOptions.length) + || candidate.optionIds.length !== currentOptions.length + || candidate.optionIds.length !== priorOptions.length + || !candidate.optionIds.every((id, index) => id === currentOptions[index]?.id) + || !candidate.optionIds.every((id, index) => id === priorOptions[index]?.id) + || next.threads.filter(item => + ["exploring", "leaning", "reopened"].includes(item.status) + && [2, 3, 4].includes( + item.contributions.filter(value => value.kind === "option").length, + ) + ).length !== 1 + : candidate.optionIds.length !== 2 + || candidate.optionIds[0] === candidate.optionIds[1] + || currentOptions.length !== 2 + ) return next; + let options = candidate.optionIds.map(id => { + let previous = prior.contributions.find(item => item.id === id && item.kind === "option"); + let current = thread.contributions.find(item => item.id === id && item.kind === "option"); + return previous && current + && (previous.displayLabel ?? previous.text) === (current.displayLabel ?? current.text) + ? current + : undefined; + }); + if (options.some(item => !item)) return next; + let selectedOptions = options as ConversationPlan.Contribution[]; + if (isConcern) { + if (cardRecordExists && !card) return next; + if ( + card && (card.cardId !== thread.questionnaireId + || card.options.length !== selectedOptions.length + || selectedOptions.some(option => + !card.options.some(item => + item.id === option.id && item.label === (option.displayLabel ?? option.text) + ) + )) + ) return next; + let focus = researchQuoteFocus(candidate.source.quote, selectedOptions); + if ( + !focus || (candidate.kind === "current-cost-concern" + && candidate.focusOptionId !== focus.focusOptionId) + ) return next; + } else if (!researchQuoteSupportsPair(candidate.source.quote, selectedOptions)) return next; + let needId = researchId("need", [candidate.threadId, kind]); + let contextId = researchId("context", [...candidate.optionIds].sort()); + if ( + (next.researchOffers ?? []).some(item => + item.source.messageId === message.id + || item.needId === needId && ( + item.status !== "dismissed" || item.contextId === contextId + ) + ) + ) return next; + let frozen = selectedOptions.map(item => ({ + id: item.id, + labelAtOffer: item.displayLabel ?? item.text, + })); + let task: ConversationPlan.ResearchTask = isConcern + ? { + kind: "current-cost-concern", + threadId: thread.id, + observedEventCount: next.events.length, + observedThreadVersion: thread.version, + options: frozen as Extract< + ConversationPlan.ResearchTask, + { kind: "current-cost-concern" } + >["options"], + ...(candidate.kind === "current-cost-concern" && candidate.focusOptionId + ? { focusOptionId: candidate.focusOptionId } + : {}), + } + : { + kind: "current-cost-comparison", + threadId: thread.id, + observedEventCount: next.events.length, + observedThreadVersion: thread.version, + options: frozen as Extract< + ConversationPlan.ResearchTask, + { kind: "current-cost-comparison" } + >["options"], + }; + let source = candidate.source; + return offerResearch(next, { + id: researchId("offer", [message.id]), + needId, + contextId, + source, + threadId: thread.id, + task, + brief: renderResearchTask(task, source), + }, message); +} diff --git a/apps/server/src/conversation-plan/processor-research-consent.ts b/apps/server/src/conversation-plan/processor-research-consent.ts new file mode 100644 index 00000000..f2d64ff8 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-consent.ts @@ -0,0 +1,90 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member, ResearchCommand, ResearchStart } from "./processor-types"; +import { actOnResearchOffer } from "./domain"; +import { appendEffects } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createResearchConsent( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, + markApplied: (key: string) => Promise, + report: (error: unknown) => void, +) { + async function researchConsent( + command: ResearchCommand, + actor: Member, + principalId: string, + start?: ResearchStart, + ): Promise> { + let changed = false; + let { offer, state, pending, applied } = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + !command || typeof command.offerId !== "string" || !command.offerId + || command.offerId.length > 200 || !actor || actor.kind !== "member" + || !actor.handle || typeof principalId !== "string" || !principalId + || principalId.length > 200 + ) throw new Error("invalid research consent action"); + let previous = plan.conversationPlan; + let previousPending = plan.conversationPlanPendingEffects; + let next = previous; + if (command.choice === "resume") { + if (command.actionId !== undefined) throw new Error("invalid research resume action"); + } else if (command.choice === "research" || command.choice === "dismiss") { + next = actOnResearchOffer(previous, command.offerId, { + id: command.actionId, + kind: command.choice, + actor, + principalId, + at: Date.now(), + }); + } else throw new Error("invalid research consent choice"); + let offer = next.researchOffers?.find(item => item.id === command.offerId); + if (!offer || command.choice === "resume" && offer.status !== "accepted") { + throw new Error("research offer is not accepted"); + } + let key = `research:${offer.id}`; + if (next !== previous) { + plan.conversationPlan = next; + try { + if (offer.status === "accepted") { + plan.conversationPlanPendingEffects = appendEffects(plan, [{ + key, + kind: "research", + offerId: offer.id, + }]); + } + await deps.persist(); + changed = true; + } catch (error) { + plan.conversationPlan = previous; + plan.conversationPlanPendingEffects = previousPending; + throw error; + } + } + let pending = plan.conversationPlanPendingEffects.some(item => item.key === key); + let applied = plan.conversationPlanEffects.includes(key); + if (offer.status === "accepted" && !pending && !applied) { + throw new Error("accepted research offer has no delivery record"); + } + return { offer: structuredClone(offer), state: next, pending, applied }; + }); + if (changed) publish(); + let base = { offerId: offer.id, status: offer.status, revision: state.revision }; + if (offer.status === "dismissed") return { ...base, execution: "none" }; + if (applied) return { ...base, execution: "started" }; + if (!pending || !start) return { ...base, execution: "pending-retry" }; + try { + let outcome = await start(offer); + if (outcome.execution === "pending-owner") return { ...base, execution: "pending-owner" }; + await markApplied(`research:${offer.id}`); + return { ...base, ...outcome }; + } catch (error) { + report(error); + return { ...base, execution: "pending-retry" }; + } + } + return researchConsent; +} diff --git a/apps/server/src/conversation-plan/processor-research-quotes.ts b/apps/server/src/conversation-plan/processor-research-quotes.ts new file mode 100644 index 00000000..22a15392 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-research-quotes.ts @@ -0,0 +1,96 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { researchNamedOptionIds } from "./validation"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +function mentionsOptionLabel(quote: string, label: string): boolean { + let names = [label, label.trim().split(/\s+/).at(-1) ?? ""]; + return names.some(name => { + if (name.length < 2 || /^(?:app|service|server|storage|provider|option)$/i.test(name)) { + return false; + } + let escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}(?=$|[^\\p{L}\\p{N}])`, "iu") + .test(quote); + }); +} + +function exactOptionName(name: string, label: string): boolean { + let normalized = name.trim().toLocaleLowerCase().replace(/\s+/g, " "); + return [label, label.trim().split(/\s+/).at(-1) ?? ""].some(alias => + alias.length >= 2 && alias.toLocaleLowerCase() === normalized + ); +} + +function exactNamedPair( + quote: string, + options: readonly Pick[], +): boolean { + let body = quote.trim().replace(/[.!?]$/, ""); + let match = body.match( + /^compare\s+(.+?)\s+(?:and|or|vs\.?|versus)\s+(.+?)(?:\s+(?:costs?|prices?|pricing))?$/i, + ) ?? body.match( + /^how do\s+(.+?)\s+and\s+(.+?)\s+(?:costs?|prices?|pricing)\s+compare$/i, + ) ?? body.match( + /^(.+?)\s+(?:vs\.?|versus)\s+(.+?)(?:\s+(?:costs?|prices?|pricing))?$/i, + ); + if (!match) return false; + let first = options.find(option => + exactOptionName(match[1]!, option.displayLabel ?? option.text) + ); + let second = options.find(option => + exactOptionName(match[2]!, option.displayLabel ?? option.text) + ); + return !!first && !!second && first !== second; +} + +export function researchQuoteSupportsPair( + quote: string, + options: readonly Pick[], +): boolean { + if (options.length !== 2) return false; + if ( + options.some(option => + option.displayLabel && mentionsOptionLabel(quote, option.text) + && !mentionsOptionLabel(quote, option.displayLabel) + ) + ) return false; + let named = + options.filter(option => mentionsOptionLabel(quote, option.displayLabel ?? option.text)).length; + if (named === 2) return exactNamedPair(quote, options); + if (named === 0) { + return /^(?:I|we) (?:don't|do not) know current provider (?:prices|pricing|costs)\.?$/i + .test(quote); + } + let concern = quote.match( + /^(.+?)['’]s\s+(?:egress|costs?|prices?|pricing|fees?)\s+(?:could|might|may)\s+matter(?:\s+for\s+([^.!?]+))?[.!?]?$/i, + ); + return !!concern + && options.some(option => mentionsOptionLabel(concern[1]!, option.displayLabel ?? option.text)) + && (!concern[2] || /^[a-z][a-z\s-]{0,80}$/.test(concern[2])); +} + +export function researchQuoteFocus( + quote: string, + options: readonly ConversationPlan.Contribution[], +): { focusOptionId?: string } | undefined { + if (![3, 4].includes(options.length)) return; + let current = options.map(item => ({ + id: item.id, + labelAtOffer: item.displayLabel ?? item.text, + })); + if ( + /^(?:I|we) (?:don't|do not) know current provider (?:prices|pricing|costs)\.?$/i.test(quote) + || /^We still need current provider (?:prices|pricing|costs)\.?$/i.test(quote) + ) { + return {}; + } + let concern = quote.match( + /^(.+?)['’]s\s+(?:egress|costs?|prices?|pricing|fees?)\s+(?:could|might|may)\s+matter(?:\s+for\s+([^.!?]+))?[.!?]?$/i, + ); + if (!concern || concern[2] && !/^[a-z][a-z\s-]{0,80}$/.test(concern[2])) return; + let named = researchNamedOptionIds(quote, current); + if (named.length !== 1) return; + let operand = researchNamedOptionIds(concern[1]!, current); + if (operand.length !== 1 || operand[0] !== named[0]) return; + return { focusOptionId: named[0] }; +} diff --git a/apps/server/src/conversation-plan/processor-run.ts b/apps/server/src/conversation-plan/processor-run.ts new file mode 100644 index 00000000..24d53498 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-run.ts @@ -0,0 +1,199 @@ +import { isDeepStrictEqual } from "node:util"; +import type { Dependencies, State } from "./processor-types"; +import { type Interpretation, interpretMessage } from "./interpret"; +import { completeAnalysis } from "./domain"; +import { assertEventCapacity, ConversationCapacityError, currentScopedProposal } from "./events"; +import { effectsFor } from "./effects"; +import { assertOptionCapacity } from "./option-capacity"; +import { cardCycle, linkedCardOptions } from "./processor-card-context"; +import { admitResearchOffer } from "./processor-research-admission"; +import { appendEffects, failure, sameThreads } from "./processor-fields"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createRun( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + controller: AbortController, + publish: () => void, + recover: () => Promise, + drainEffects: () => Promise, +) { + async function run(): Promise { + await recover(); + await drainEffects(); + while (active()) { + let stale = 0; + while (active()) { + let input = await deps.exclusive(async () => { + if (!active()) return undefined; + let item = plan.conversationPlan.queue.find(item => item.status === "pending"); + if (!item) return undefined; + let index = plan.chat.entries.findIndex(entry => entry.id === item.messageId); + if (index < 0) throw new Error("queued conversation message is missing"); + return { + channelId: plan.id, + message: structuredClone(plan.chat.entries[index]!), + recent: structuredClone(plan.chat.entries.slice(0, index).slice(-12)), + state: structuredClone(plan.conversationPlan), + linkedCards: linkedCardOptions(plan, plan.conversationPlan), + cardCycles: new Map(plan.conversationPlan.threads.map(thread => [ + thread.id, + cardCycle(plan, thread), + ])), + }; + }); + if (!input) return false; + let { cardCycles, ...interpretInput } = input; + let interpretation: Interpretation; + try { + interpretation = await (deps.interpret ?? interpretMessage)( + interpretInput, + controller.signal, + ); + } catch { + interpretation = { events: [], analysis: failure("Jev interpretation failed") }; + } + let outcome = await deps.exclusive(async () => { + if (!active()) return "stopped" as const; + let current = plan.conversationPlan; + if ( + !current.queue.some(item => + item.messageId === interpretInput.message.id && item.status === "pending" + ) + ) return "gone" as const; + let affected = new Set(interpretation.events.map(event => event.threadId)); + if (interpretation.researchOffer) affected.add(interpretation.researchOffer.threadId); + if ( + plan.pendingCardActions.some(action => + affected.has(action.threadId) + && ["decided", "reopened", "discarded"].includes(action.kind) + ) + ) return "waiting" as const; + if (!sameThreads(interpretInput.state, current)) return "stale" as const; + let currentCards = linkedCardOptions(plan, current); + if ( + [...affected].some(id => + !isDeepStrictEqual(interpretInput.linkedCards.get(id), currentCards.get(id)) + ) + ) return "stale" as const; + if ( + [...affected].some(id => { + let thread = current.threads.find(item => item.id === id); + return thread && !isDeepStrictEqual(cardCycles.get(id), cardCycle(plan, thread)); + }) + ) return "stale" as const; + let admittedEvents = interpretation.events.filter(event => { + if ( + event.type !== "scoped-choice.proposed" + && event.type !== "scoped-choice.agreed" + ) return true; + let thread = current.threads.find(item => item.id === event.threadId); + let card = currentCards.get(event.threadId); + return thread?.questionnaireId === event.cardId && card?.cardId === event.cardId + && card.options.filter(item => + item.id === event.optionId && item.label === event.label + ).length === 1 + && (event.type !== "scoped-choice.agreed" || !!thread + && currentScopedProposal(thread, current.events)?.id === event.proposalId); + }); + let next: State; + try { + next = completeAnalysis( + current, + interpretInput.message.id, + admittedEvents, + interpretInput.message, + interpretation.analysis, + ); + assertEventCapacity(next, plan.pendingCardActions.length); + assertOptionCapacity({ ...plan, conversationPlan: next }); + } catch (error) { + next = completeAnalysis( + current, + interpretInput.message.id, + [], + interpretInput.message, + failure( + error instanceof ConversationCapacityError + ? error.message + : "analysis failed validation", + ), + ); + } + try { + let researchThread = next.threads.find(item => + item.id === interpretation.researchOffer?.threadId + ); + let cardId = researchThread?.questionnaireId; + next = admitResearchOffer( + current, + next, + interpretInput.message, + interpretation.researchOffer, + currentCards.get(researchThread?.id ?? ""), + !!cardId && plan.records.has(cardId), + ); + } catch { + // A rejected optional offer must not discard valid planning events. + } + let pending = plan.conversationPlanPendingEffects; + plan.conversationPlan = next; + try { + let record = next.analysis.find(item => item.messageId === interpretInput.message.id); + let accepted = next.events.filter(event => record?.eventIds.includes(event.id)); + plan.conversationPlanPendingEffects = appendEffects( + plan, + effectsFor(accepted, next, record, plan.records), + ); + await deps.persist(); + return "committed" as const; + } catch (error) { + plan.conversationPlan = current; + plan.conversationPlanPendingEffects = pending; + throw error; + } + }); + if (outcome === "stopped") return false; + if (outcome === "waiting") return true; + if (outcome === "stale") { + if (++stale < 3) continue; + let failed = await deps.exclusive(async () => { + if (!active()) return false; + let current = plan.conversationPlan; + let message = plan.chat.entries.find(entry => entry.id === interpretInput.message.id); + if ( + !message + || !current.queue.some(item => + item.messageId === interpretInput.message.id && item.status === "pending" + ) + ) return false; + plan.conversationPlan = completeAnalysis( + current, + message.id, + [], + message, + failure("analysis changed during inference; retry"), + ); + try { + await deps.persist(); + return true; + } catch (error) { + plan.conversationPlan = current; + throw error; + } + }); + if (failed) publish(); + break; + } + if (outcome === "committed") { + publish(); + await drainEffects(); + } + break; + } + } + return false; + } + return run; +} diff --git a/apps/server/src/conversation-plan/processor-scoped-save.ts b/apps/server/src/conversation-plan/processor-scoped-save.ts new file mode 100644 index 00000000..c56bd987 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-scoped-save.ts @@ -0,0 +1,115 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { Dependencies, Member } from "./processor-types"; +import { activeScopedSupport, applyEvent, currentScopedProposal } from "./events"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export function createScopedSave( + deps: Dependencies, + plan: Dependencies["plan"], + active: () => boolean, + publish: () => void, +) { + async function saveScopedChoice(input: ConversationPlan.ScopedChoiceSave, actor: Member) { + let changed = false; + let result = await deps.exclusive(async () => { + if (!active()) throw new Error("conversation analysis is unavailable"); + if ( + !input || typeof input !== "object" || Array.isArray(input) + || Object.keys(input).some(key => + ![ + "actionId", + "threadId", + "expectedVersion", + "proposalId", + "cardId", + "optionId", + "expectedLabel", + "expectedGeneration", + ].includes(key) + ) + || actor?.kind !== "member" || !actor.handle || actor.handle.length > 200 + || typeof input.actionId !== "string" + || !/^[A-Za-z0-9._:-]{1,128}$/.test(input.actionId) + || [input.threadId, input.proposalId, input.cardId, input.optionId].some(value => + typeof value !== "string" || !value || value.length > 200 + ) + || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0 + || !Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 0 + || input.expectedLabel !== undefined && ( + typeof input.expectedLabel !== "string" || !input.expectedLabel.trim() + || input.expectedLabel.length > 200 + ) + ) throw new Error("invalid scoped choice save action"); + let previous = plan.conversationPlan; + let eventId = `human:${actor.handle}:${input.actionId}`; + let existing = previous.events.find(event => event.id === eventId); + if (existing) { + if ( + existing.type !== "scoped-choice.saved" + || existing.actor.kind !== "member" || existing.actor.handle !== actor.handle + || existing.threadId !== input.threadId + || existing.observedThreadVersion !== input.expectedVersion + || existing.proposalId !== input.proposalId + || existing.cardId !== input.cardId || existing.optionId !== input.optionId + || existing.expectedLabel !== input.expectedLabel + || existing.expectedGeneration !== input.expectedGeneration + ) throw new Error("scoped choice action ID already used"); + return { eventId, revision: previous.revision }; + } + let thread = previous.threads.find(item => item.id === input.threadId); + let record = plan.records.get(input.cardId); + let proposal = thread && currentScopedProposal(thread, previous.events); + let options = record?.definition.questions[0]?.options ?? []; + let option = options.filter(item => item.id === input.optionId); + if ( + !thread || thread.version !== input.expectedVersion + || thread.status === "decided" || thread.status === "discarded" + || thread.questionnaireId !== input.cardId || !proposal + || proposal.id !== input.proposalId || proposal.optionId !== input.optionId + || !record || record.threadId !== thread.id + || (record.status !== "open" && record.status !== "reopened") + || record.history.length !== input.expectedGeneration + || option.length !== 1 || option[0].label !== proposal.label + || input.expectedLabel !== undefined && input.expectedLabel !== proposal.label + ) throw new Error("scoped choice card or proposal is stale"); + let support = activeScopedSupport(previous.events, proposal); + if (support.length === 0) throw new Error("scoped choice has no active support"); + let legacy = support[0]?.id === proposal.id && support.length <= 2; + let agreement = legacy && support[1]?.type === "scoped-choice.agreed" + ? support[1] + : undefined; + let saved: ConversationPlan.Event = { + id: eventId, + type: "scoped-choice.saved", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "human", + actor, + at: Date.now(), + proposalId: proposal.id, + ...(legacy ? {} : { supportEventIds: support.map(item => item.id) }), + ...(agreement ? { agreementId: agreement.id } : {}), + cardId: proposal.cardId, + optionId: proposal.optionId, + label: proposal.label, + scope: proposal.scope, + sources: support.map(item => item.source), + expectedGeneration: input.expectedGeneration, + ...(input.expectedLabel === undefined ? {} : { expectedLabel: input.expectedLabel }), + }; + let next = applyEvent(previous, saved); + plan.conversationPlan = next; + try { + await deps.persist(); + } catch (error) { + plan.conversationPlan = previous; + throw error; + } + changed = true; + return { eventId, revision: next.revision }; + }); + if (changed) publish(); + return result; + } + return saveScopedChoice; +} diff --git a/apps/server/src/conversation-plan/processor-types.ts b/apps/server/src/conversation-plan/processor-types.ts new file mode 100644 index 00000000..4bcc3294 --- /dev/null +++ b/apps/server/src/conversation-plan/processor-types.ts @@ -0,0 +1,43 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { EffectDeps } from "./effects"; +import type { Interpretation, InterpretInput } from "./interpret"; +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export type State = ConversationPlan.State; + +export type Member = Extract; + +export type Analysis = Omit; + +export type Dependencies = { + plan: Pick< + Plan, + | "id" + | "chat" + | "conversationPlan" + | "conversationPlanRetries" + | "conversationPlanEffects" + | "conversationPlanPendingEffects" + | "pendingCardActions" + | "records" + >; + exclusive: (action: () => Promise) => Promise; + persist: () => Promise; + publish: (state: State) => void; + active: () => boolean; + interpret?: (input: InterpretInput, signal: AbortSignal) => Promise; + effects?: EffectCommands; + onError?: (error: unknown) => void; +}; + +export type EffectCommands = Omit; + +export type ResearchCommand = + | { offerId: string; choice: "research" | "dismiss"; actionId: string } + | { offerId: string; choice: "resume"; actionId?: never }; + +export type ResearchStart = (offer: ConversationPlan.ResearchOffer) => Promise< + | { execution: "started"; researchRequestId: string } + | { execution: "pending-owner" } +>; diff --git a/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts b/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts new file mode 100644 index 00000000..9b2bbf0d --- /dev/null +++ b/apps/server/src/conversation-plan/prompt-scripted-harness.test.ts @@ -0,0 +1,244 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPromptScriptedHarness } from "../../../../e2e/harness/scripted-planner"; +import type { HarnessV1StreamPart } from "@ai-sdk/harness"; + +type Call = Extract; +const HEADING = "[Background job: heading]\nDraft the title."; +const REFINE = '[Background job: refine]\nDecision card card-a: "Which approach?"'; +const SCRIPT = [{ tool: "refine_decision", args: { revision: "$revision", id: "$target" } }]; + +async function contract( + script: unknown = SCRIPT, + observe: Parameters[1] = {}, +) { + let dir = await mkdtemp(join(tmpdir(), "prompt-scripted-contract-")); + for (let kind of ["heading", "refine", "suggest", "prose"]) { + await writeFile(join(dir, `${kind}.json`), JSON.stringify(script)); + } + let driver = createPromptScriptedHarness(dir, observe); + let session = await driver.fake.doStart({ sessionId: "prompt-contract" } as never); + let parts: HarnessV1StreamPart[] = []; + let queue: Call[] = []; + let waiting: Array<(call: Call) => void> = []; + return { + dir, + driver, + session, + parts, + run(prompt: unknown, signal?: AbortSignal) { + return session.doPromptTurn({ + prompt, + tools: [], + abortSignal: signal, + emit(part: HarnessV1StreamPart) { + parts.push(part); + if (part.type !== "tool-call") return; + let listener = waiting.shift(); + if (listener) listener(part); + else queue.push(part); + }, + } as never); + }, + next() { + let call = queue.shift(); + if (call) return Promise.resolve(call); + let next = Promise.withResolvers(); + waiting.push(next.resolve); + return next.promise; + }, + async [Symbol.asyncDispose]() { + await driver.fake.shutdown(); + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +test.each([ + "", + "ordinary message", + "\n" + HEADING, + "[Background job: unknown]", + HEADING + "\n[Background job: refine]", + HEADING + '\nDecision card card-a: "Question?"', + "[Background job: refine]", + REFINE + '\nDecision card card-b: "Other?"', + "[Background job: suggest]\nDecision card id: card-a", + "[Background job: prose]\nDecision card id: " + "x".repeat(201), + { role: "user", content: [{ type: "image", image: "invalid" }] }, +])("rejects invalid or ambiguous prompt context %# before any model tool call", async prompt => { + await using h = await contract(); + let turn = await h.run(prompt); + await turn.done; + expect(h.driver.calls).toEqual([]); + expect(h.parts.filter(part => part.type === "error")).toHaveLength(1); + expect(h.parts.filter(part => part.type === "finish-step")).toHaveLength(1); +}); + +test.each([ + [HEADING, "document"], + [REFINE, "card-a"], + ['[Background job: suggest]\nDecision card card-b: "Question?"', "card-b"], + ["[Background job: prose]\nDecision card id: card-c", "card-c"], +])( + "reads returned revision and derives only the exact prompt target %#", + async (prompt, target) => { + await using h = await contract(); + let turn = await h.run({ role: "user", content: [{ type: "text", text: prompt }] }); + let read = await h.next(); + expect(read.toolName).toBe("read_plan"); + expect(JSON.parse(read.input)).toEqual({}); + await turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":7}' }); + let call = await h.next(); + expect(JSON.parse(call.input)).toEqual({ revision: 7, id: target }); + await turn.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan", "refine_decision"]); + expect(h.driver.results).toHaveLength(2); + expect(h.driver.tools).toEqual([[]]); + expect(h.parts.filter(part => part.type === "error")).toEqual([]); + }, +); + +test.each([ + { output: "bad JSON" }, + { output: "null" }, + { output: "[]" }, + { output: "{}" }, + { output: '{"revision":-1}' }, + { output: '{"revision":1.5}' }, + { output: '{"revision":"7"}' }, + { output: '{"revision":9007199254740992}' }, + { output: '{"revision":7}', isError: true }, + { output: { revision: 7 } }, +])("refuses invalid read_plan result %# before the dependent scripted call", async result => { + await using h = await contract(); + let turn = await h.run(REFINE); + let read = await h.next(); + await turn.submitToolResult({ toolCallId: read.toolCallId, ...result }); + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); + expect(h.parts.filter(part => part.type === "error")).toHaveLength(1); +}); + +test("sequential original calls each use a new returned revision without tool prefiltering", async () => { + await using h = await contract([ + ...SCRIPT, + { tool: "edit_plan", args: { revision: "$revision", nested: ["$target", "$revision"] } }, + ]); + let turn = await h.run(REFINE); + for (let revision of [2, 9]) { + let read = await h.next(); + expect(read.toolName).toBe("read_plan"); + await turn.submitToolResult({ + toolCallId: read.toolCallId, + output: JSON.stringify({ revision }), + }); + let call = await h.next(); + expect(JSON.parse(call.input).revision).toBe(revision); + if (revision === 9) expect(JSON.parse(call.input).nested).toEqual(["card-a", 9]); + await turn.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + } + await turn.done; + expect(h.driver.calls.map(call => call.toolName)).toEqual([ + "read_plan", + "refine_decision", + "read_plan", + "edit_plan", + ]); + expect(h.driver.results).toHaveLength(4); +}); + +test("an empty original script finishes without an added read or dependent call", async () => { + await using h = await contract([]); + let turn = await h.run(HEADING); + await turn.done; + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(h.parts.filter(part => part.type === "finish-step")).toHaveLength(1); +}); + +test("local stop releases a held worker without aborting the caller", async () => { + await using h = await contract(); + await writeFile(join(h.dir, "heading.hold"), "hold"); + let controller = new AbortController(); + let escape: ReturnType | undefined; + try { + let turn = await h.run(HEADING, controller.signal); + await h.driver.entered; + escape = setTimeout(() => controller.abort(), 500); + await h.session.doStop(); + await turn.done; + expect(controller.signal.aborted).toBe(false); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + } finally { + clearTimeout(escape); + controller.abort(); + } +}); + +test("shutdown and done drain the admitted result observer before idempotent destruction", async () => { + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let finished = false; + await using h = await contract(SCRIPT, { + onResult: async () => { + entered.resolve(); + await release.promise; + finished = true; + }, + }); + let turn = await h.run(REFINE); + let read = await h.next(); + let submitted = turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":3}' }); + await entered.promise; + let drained = false; + let done = false; + void turn.done.then(() => { + done = true; + }); + let shutdown = h.driver.fake.shutdown().then(() => { + drained = true; + }); + try { + await Bun.sleep(10); + expect(finished).toBe(false); + expect(done).toBe(false); + expect(drained).toBe(false); + expect(h.driver.destroyed()).toBe(0); + } finally { + release.resolve(); + await submitted; + await shutdown; + } + await turn.done; + expect(finished).toBe(true); + expect(done).toBe(true); + expect(drained).toBe(true); + expect(h.driver.destroyed()).toBe(1); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); + expect(h.parts.filter(part => part.type === "tool-result")).toEqual([]); + await h.session.doDestroy(); + await h.driver.fake.shutdown(); + expect(h.driver.destroyed()).toBe(1); + await expect(h.driver.fake.doStart({ sessionId: "late" } as never)).rejects.toThrow("shut down"); +}); + +test("an admitted observer failure surfaces on the model stream and blocks dependent calls", async () => { + await using h = await contract(SCRIPT, { + onResult: () => { + throw new Error("observer failed"); + }, + }); + let turn = await h.run(REFINE); + let read = await h.next(); + await turn.submitToolResult({ toolCallId: read.toolCallId, output: '{"revision":3}' }); + await turn.done; + let errors = h.parts.filter(part => part.type === "error"); + expect(errors).toHaveLength(1); + expect(String(errors[0]!.error)).toContain("observer failed"); + expect(h.driver.calls.map(call => call.toolName)).toEqual(["read_plan"]); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts b/apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts new file mode 100644 index 00000000..ebebbf78 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-card-focus.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; +import { + ada, + d03Options, + entry, + exactSource, + expectNoDurableOffer, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + resendId, + s3Id, + seededThread, + sesId, + sharedDiskId, + waitFor, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("rejects missing, partial, and ambiguous R2 focus names", async () => { + let message = entry("D04-invalid-r2-focus", "R2's egress could matter for thumbnails.", 11); + let base: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]), + entry("D04-b2-not-present", "B2's egress could matter for thumbnails.", 11), + { ...base, quote: "B2's egress could matter for thumbnails." }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]), + entry("D04-r2x-not-whole-token", "R2x's egress could matter for thumbnails.", 11), + { ...base, quote: "R2x's egress could matter for thumbnails." }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + { id: resendId, text: "Wasabi R2" }, + ]), + message, + { ...base, optionIds: [s3Id, sharedDiskId, r2Id, resendId] }, + ); + await expectNoDurableOffer( + seededThread("storage", "Where should images live?", [ + { id: resendId, text: "Amazon S3 / Cloudflare R2" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Wasabi" }, + ]), + entry( + "D04-compound-option-focus", + "Amazon S3 / Cloudflare R2's egress could matter for thumbnails.", + 11, + ), + { + ...base, + optionIds: [resendId, sharedDiskId, r2Id], + quote: "Amazon S3 / Cloudflare R2's egress could matter for thumbnails.", + scopeChoice: resendId, + }, + ); + }); + + test("revalidates a research-only offer against the linked card at commit", async () => { + let state = d03Options(); + let message = entry( + "D03-card-changed-during-interpretation", + "I don't know current provider prices.", + 11, + ); + let ready!: () => void; + let release!: () => void; + let interpretationStarted = new Promise(resolve => ready = resolve); + let allowCommit = new Promise(resolve => release = resolve); + let setup = harness(state, async () => { + ready(); + await allowCommit; + return { + events: [], + researchOffer: { + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + }; + }); + let originalCard = { + threadId: "mail", + status: "open", + definition: { + questions: [{ + options: [ + { id: relayId, label: "SMTP relay" }, + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + ], + }], + }, + history: [], + }; + setup.plan.records.set("card-mail", originalCard as never); + + await setup.processor.accept(message); + setup.processor.afterMessage(); + await interpretationStarted; + setup.plan.records.set("card-mail", { + ...originalCard, + definition: { + questions: [{ + options: [ + ...originalCard.definition.questions[0]!.options, + { id: resendId, label: "Resend" }, + ], + }], + }, + } as never); + release(); + await waitFor(() => setup.durable.state.analysis.some(item => item.messageId === message.id)); + + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.threads.find(item => item.id === "mail")?.contributions) + .toHaveLength(3); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)).toMatchObject({ + messageId: message.id, + status: "unlinked", + }); + expect(setup.errors).toEqual([]); + setup.processor.stop(); + }); + + test("does not offer from a card whose option snapshot already differs from its thread", async () => { + let state = d03Options(); + let message = entry( + "D03-card-already-has-fourth-option", + "I don't know current provider prices.", + 11, + ); + let setup = harness(state, async input => { + expect(input.linkedCards?.get("mail")?.options).toHaveLength(4); + return { + events: [], + researchOffer: { + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + }; + }); + setup.plan.records.set("card-mail", { + threadId: "mail", + status: "open", + definition: { + questions: [{ + options: [ + { id: relayId, label: "SMTP relay" }, + { id: postmarkId, label: "Postmark" }, + { id: sesId, label: "Amazon SES" }, + { id: resendId, label: "Resend" }, + ], + }], + }, + history: [], + } as never); + + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)).toMatchObject({ + messageId: message.id, + status: "unlinked", + }); + expect(setup.errors).toEqual([]); + setup.processor.stop(); + }); + + test("dismissed D03 current-price uncertainty suppresses a generic same-context paraphrase", async () => { + let state = d03Options(); + let first = entry("D03-m4-dismiss", "I don't know current provider prices.", 11); + let second = entry("D03-m7-paraphrase", "We still need current provider pricing.", 12); + let setup = harness( + state, + inputInterpreter(message => ({ + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + })), + ); + let starts = 0; + await processMessage(setup, first); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer?.status).toBe("offered"); + let dismissed = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "dismiss", actionId: "D03-dismiss" }, + ada, + "ada-principal", + async () => { + starts++; + return { execution: "started", researchRequestId: "should-not-start" }; + }, + ); + expect(dismissed.execution).toBe("none"); + await processMessage(setup, second); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.researchOffers?.[0]?.status).toBe("dismissed"); + expect(starts).toBe(0); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts b/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts new file mode 100644 index 00000000..220ed974 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-comparison-guards.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import type { Interpretation } from "./interpret"; +import type { JevAnswer } from "./jev"; +import { + ada, + d03Options, + detectResearchOffer, + entry, + exactSource, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + resendId, + s3Id, + seededThread, + sesId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("a replayed D03 generic concern does not publish a duplicate offer", async () => { + let state = d03Options(); + let first = entry("D03-replay-first", "I don't know current provider prices.", 11); + let replay = entry("D03-replay-again", "I don't know current provider prices.", 12); + let setup = harness( + state, + inputInterpreter(() => ({ + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: first.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + })), + ); + await processMessage(setup, first); + await processMessage(setup, replay); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.analysis.some(item => item.messageId === first.id)).toBe(true); + expect(setup.durable.state.analysis.some(item => item.messageId === replay.id)).toBe(true); + expect(setup.durable.entries.filter(item => item.id === replay.id)).toHaveLength(1); + setup.processor.stop(); + }); + + test("rejects a stale label at admission but accepts a fresh quote naming the current label", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); + state = applyEvent(state, { + id: "rename-r2", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Backblaze B2", + observedCardRevision: 0, + }); + let staleMessage = entry( + "storage-old-label", + "Compare Amazon S3 and Cloudflare R2 costs.", + 11, + ); + let staleJudgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: staleMessage.text, + }; + let stale = await detectResearchOffer(state, staleMessage, staleJudgment); + expect(stale.researchOffer).toBeDefined(); + let staleSetup = harness(state, inputInterpreter(() => staleJudgment)); + await processMessage(staleSetup, staleMessage); + expect(staleSetup.durable.state.researchOffers ?? []).toEqual([]); + expect(staleSetup.durable.state.analysis.some(item => item.messageId === staleMessage.id)) + .toBe(true); + staleSetup.processor.stop(); + + let currentMessage = entry( + "storage-current-label", + "Compare Amazon S3 and Backblaze B2 costs.", + 12, + ); + let currentJudgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: currentMessage.text, + }; + let current = await detectResearchOffer(state, currentMessage, currentJudgment); + expect(current.researchOffer).toMatchObject({ + threadId: "storage", + optionIds: [s3Id, r2Id], + source: exactSource(currentMessage, currentMessage.text), + }); + + let setup = harness(state, inputInterpreter(() => currentJudgment)); + await processMessage(setup, currentMessage); + expect(setup.durable.state.researchOffers?.[0]).toMatchObject({ + task: { + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: r2Id, labelAtOffer: "Backblaze B2" }, + ], + }, + brief: + `Compare current costs for Amazon S3 and Backblaze B2. Discussion context: “${currentMessage.text}”`, + }); + setup.processor.stop(); + }); + + test("does not pair a selected option with a different explicitly named option", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + { id: resendId, text: "Redis" }, + ]); + let message = entry("wrong-pair", "Compare Amazon S3 and Redis costs.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: message.text, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toBeUndefined(); + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)).toBe(true); + setup.processor.stop(); + }); + + for ( + let scenario of [ + { + name: "does not turn a named outside alternative into an S3/R2 comparison", + id: "outside-option", + text: "Compare Amazon S3 and Redis costs.", + ts: 11, + }, + { + name: "does not pair S3/R2 when a third provider is also named", + id: "multiple-named-providers", + text: "Compare Amazon S3 and Cloudflare R2 and Redis costs.", + ts: 11, + }, + { + name: "rejects an outside operand in a short explicit alternative", + id: "outside-or", + text: "Amazon S3 or Redis costs?", + ts: 11, + }, + { + name: "rejects an outside operand in a how-do-they-compare question", + id: "outside-how-compare", + text: "How do Amazon S3 and Redis costs compare?", + ts: 12, + }, + { + name: "rejects an outside option in a comparative-cost statement", + id: "outside-than", + text: "Amazon S3 is cheaper than Redis at current prices.", + ts: 13, + }, + ] + ) { + test(scenario.name, async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); + let message = entry(scenario.id, scenario.text, scenario.ts); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: message.text, + }; + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)) + .toBe(true); + setup.processor.stop(); + }); + } + + test("does not offer quoted cost concerns owned only by the research follow-up", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("quoted-cost", "They asked, ‘I don't know current provider prices.’", 11); + let result = await detectResearchOffer(state, message, { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: "I don't know current provider prices.", + triageOwned: 0.1, + owned: 0.99, + }); + expect(result.researchOffer).toBeUndefined(); + }); + + test("requires an open two-option thread and confident, unanswered, speaker-owned evidence", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("guarded-cost", "I don't know current provider prices.", 11); + let base: ResearchJudgment = { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + }; + let lowConfidence: JevAnswer = { + type: "choice", + choice: postmarkId, + confidence: 0.79, + probabilities: { [postmarkId]: 0.95, [sesId]: 0.05, none: 0 }, + }; + let cases: Array<{ state: ConversationPlan.State; judgment: ResearchJudgment }> = [ + { state, judgment: { ...base, need: 0.79 } }, + { state, judgment: { ...base, answered: 0.95 } }, + { state, judgment: { ...base, optionAAnswer: lowConfidence } }, + { state, judgment: { ...base, triageOwned: 0.1, owned: 0.1 } }, + { + state: seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + ]), + judgment: base, + }, + { + state: seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ], "decided"), + judgment: base, + }, + ]; + for (let item of cases) { + let result = await detectResearchOffer(item.state, message, item.judgment); + expect(result.researchOffer).toBeUndefined(); + } + }); + + test("suppresses a dismissed same-context paraphrase without starting research", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let first = entry("dismiss-first", "I don't know current provider prices.", 11); + let second = entry("dismiss-paraphrase", "We still need current provider pricing.", 12); + let setup = harness( + state, + inputInterpreter(message => ({ + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + })), + ); + let starts = 0; + await processMessage(setup, first); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer?.status).toBe("offered"); + let dismissed = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "dismiss", actionId: "dismiss-first" }, + ada, + "ada-principal", + async () => { + starts++; + return { execution: "started", researchRequestId: "should-not-start" }; + }, + ); + expect(dismissed.execution).toBe("none"); + await processMessage(setup, second); + expect(setup.durable.state.researchOffers).toHaveLength(1); + expect(setup.durable.state.researchOffers?.[0]?.status).toBe("dismissed"); + expect(starts).toBe(0); + setup.processor.stop(); + }); + + test("persists valid planning events when the optional offer is rejected", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("planning-survives", "Postmark is the current preference.", 11); + let candidate: NonNullable = { + source: exactSource(message, message.text), + threadId: "mail", + optionIds: [postmarkId, "not-a-current-option"], + }; + let event: ConversationPlan.Event = { + id: "support-postmark", + type: "stance.changed", + threadId: "mail", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { ...candidate.source, role: "support" }, + optionId: postmarkId, + position: "support", + }; + let setup = harness(state, async () => ({ + events: [event], + researchOffer: candidate, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "applied", + passes: [], + }, + })); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.threads[0]?.stances).toContainEqual( + expect.objectContaining({ participant: "ada", optionId: postmarkId, position: "support" }), + ); + expect(setup.durable.state.analysis.find(item => item.messageId === message.id)?.eventIds) + .toContain(event.id); + setup.processor.stop(); + }); + + test("publishes no offer when the offer-and-analysis commit fails", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("failed-offer-commit", "I don't know current provider prices.", 11); + let setup = harness( + state, + inputInterpreter(() => ({ + threadId: "mail", + optionIds: [postmarkId, sesId], + quote: message.text, + })), + ); + setup.failPersistenceAfter(2); + await processMessage(setup, message); + expect(setup.errors.length).toBeGreaterThan(0); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.publications.every(snapshot => !snapshot.researchOffers?.length)).toBe(true); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts b/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts new file mode 100644 index 00000000..8bb7b303 --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-concern-guards.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import type { JevAnswer } from "./jev"; +import { renderResearchTask } from "./validation"; +import { + d03Options, + d04Options, + entry, + exactSource, + expectNoDurableOffer, + harness, + inputInterpreter, + postmarkCopyId, + postmarkId, + processMessage, + r2Id, + relayCopyId, + relayId, + resendId, + s3Id, + seededThread, + sesCopyId, + sesId, + sharedDiskId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("three- and four-option concern admission fails closed on weak or stale evidence", async () => { + let state = d03Options(); + let generic = entry("weak-three-option", "I don't know current provider prices.", 11); + let ids = [relayId, postmarkId, sesId]; + let base: ResearchJudgment = { + threadId: "mail", + optionIds: ids, + quote: generic.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let fourOptions = seededThread("four-providers", "Which mail provider?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + { id: resendId, text: "Resend" }, + ]); + let fourOptionMessage = entry( + "four-option-price-need", + "I don't know current provider prices.", + 11, + ); + let fourOptionJudgment: ResearchJudgment = { + threadId: "four-providers", + optionIds: [relayId, postmarkId, sesId, resendId], + quote: fourOptionMessage.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let fourOptionSetup = harness( + fourOptions, + inputInterpreter(() => fourOptionJudgment), + ); + await processMessage(fourOptionSetup, fourOptionMessage); + let fourOptionOffer = fourOptionSetup.durable.state.researchOffers?.[0]; + expect(fourOptionOffer).toMatchObject({ + status: "offered", + source: exactSource(fourOptionMessage, fourOptionMessage.text), + task: { + kind: "current-cost-concern", + options: [ + { id: relayId, labelAtOffer: "SMTP relay" }, + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + { id: resendId, labelAtOffer: "Resend" }, + ], + }, + }); + expect(fourOptionOffer?.brief).toBe( + renderResearchTask(fourOptionOffer!.task!, fourOptionOffer!.source), + ); + expect(fourOptionSetup.durable.pending).toEqual([]); + expect(fourOptionSetup.durable.receipts).toEqual([]); + fourOptionSetup.processor.stop(); + + await expectNoDurableOffer(state, generic, { ...base, triageOwned: 0.1 }); + await expectNoDurableOffer(state, generic, { ...base, owned: 0.1 }); + await expectNoDurableOffer(state, generic, { ...base, answered: 0.95 }); + + let quoted = entry( + "quoted-three-option", + "Mina asked, ‘I don't know current provider prices.’", + 12, + ); + await expectNoDurableOffer(state, quoted, { + ...base, + quoteChoice: "none", + }); + let reported = entry( + "reported-three-option", + "Mina reported uncertainty about provider prices.", + 13, + ); + await expectNoDurableOffer(state, reported, { + ...base, + quoteChoice: "none", + }); + let conditional = entry( + "conditional-three-option", + "If prices are unclear, we could research later.", + 14, + ); + await expectNoDurableOffer(state, conditional, { + ...base, + quoteChoice: "none", + }); + + let weakThread: JevAnswer = { + type: "choice", + choice: "none", + confidence: 0.7, + probabilities: { mail: 0.6, none: 0.4 }, + }; + await expectNoDurableOffer(state, generic, { ...base, threadAnswer: weakThread }); + + let ambiguous = d03Options(); + ambiguous = applyEvent(ambiguous, { + id: "open-mail-copy", + type: "thread.opened", + threadId: "mail-copy", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: ambiguous.events.length + 1, + question: "Which provider should handle email delivery?", + }); + for (let [index, id] of [relayCopyId, postmarkCopyId, sesCopyId].entries()) { + ambiguous = applyEvent(ambiguous, { + id: `copy-option-${id}`, + type: "option.added", + threadId: "mail-copy", + observedThreadVersion: ambiguous.threads.at(-1)!.version, + origin: "planner", + actor: { kind: "agent" }, + at: ambiguous.events.length + 1, + contribution: { + id, + text: ["SMTP relay", "Postmark", "Amazon SES"][index]!, + authoring: "scribe", + }, + }); + } + ambiguous = applyEvent(ambiguous, { + id: "link-mail-copy", + type: "card.linked", + threadId: "mail-copy", + observedThreadVersion: ambiguous.threads.at(-1)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: ambiguous.events.length + 1, + questionnaireId: "card-mail-copy", + }); + await expectNoDurableOffer(ambiguous, generic, base); + + let five = seededThread("five-providers", "Which mail provider?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + { id: resendId, text: "Resend" }, + { id: sharedDiskId, text: "Mailgun" }, + ]); + await expectNoDurableOffer(five, generic, { + ...base, + threadId: "five-providers", + optionIds: [relayId, postmarkId, sesId, resendId, sharedDiskId], + }); + + let storage = d04Options(); + storage = applyEvent(storage, { + id: "rename-r2-before-cost-concern", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: storage.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Backblaze B2", + observedCardRevision: 0, + }); + let stale = entry("stale-three-option", "R2's egress could matter for thumbnails.", 11); + await expectNoDurableOffer(storage, stale, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: stale.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }); + + let foreign = applyEvent(d04Options(), { + id: "open-foreign-option-thread", + type: "thread.opened", + threadId: "other", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + question: "Which queue?", + }); + foreign = applyEvent(foreign, { + id: "add-foreign-option", + type: "option.added", + threadId: "other", + observedThreadVersion: foreign.threads[1]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 11, + contribution: { id: resendId, text: "Resend", authoring: "scribe" }, + }); + let egress = entry("foreign-focus", "R2's egress could matter for thumbnails.", 12); + await expectNoDurableOffer(foreign, egress, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: egress.text, + kind: "current-cost-concern", + scopeAnswer: { + type: "choice", + choice: resendId, + confidence: 0.95, + probabilities: { [resendId]: 0.95, none: 0.05 }, + }, + }); + + let multiName = entry( + "multi-name-three-option", + "R2's egress and Amazon S3 request prices could matter.", + 13, + ); + await expectNoDurableOffer(d04Options(), multiName, { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: multiName.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }); + let explicitPair = entry( + "explicit-pair-three-options", + "Compare Amazon S3 and Cloudflare R2 costs.", + 14, + ); + await expectNoDurableOffer(d04Options(), explicitPair, { + threadId: "storage", + optionIds: [s3Id, r2Id], + quote: explicitPair.text, + kind: "current-cost-comparison", + }); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts b/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts new file mode 100644 index 00000000..9b4aeade --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection-positive.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import type { Interpretation } from "./interpret"; +import { renderResearchTask } from "./validation"; +import { + ada, + d03Options, + d04Options, + detectResearchOffer, + entry, + exactSource, + harness, + inputInterpreter, + postmarkId, + processMessage, + r2Id, + relayId, + s3Id, + seededThread, + sesId, + sharedDiskId, +} from "./research-offer-detection.test-fixtures"; +import type { ResearchJudgment } from "./research-offer-detection.test-fixtures"; + +// Whole archived callbacks; only suite wrappers and imports change. +describe("proactive current-cost research offers", () => { + test("uses a relabeled option's effective card label without starting work", async () => { + let state = seededThread("storage", "Where should originals live?", [ + { id: s3Id, text: "S3" }, + { id: r2Id, text: "R2" }, + ]); + state = applyEvent(state, { + id: "rename-r2-for-card", + type: "option.relabeled", + threadId: "storage", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 10, + optionId: r2Id, + label: "Cloudflare R2", + observedCardRevision: 0, + }); + let message = entry("D04-m5", "Cloudflare R2's egress could matter for thumbnails.", 11); + let quote = "Cloudflare R2's egress could matter for thumbnails."; + let candidate: NonNullable = { + source: exactSource(message, quote), + threadId: "storage", + optionIds: [s3Id, r2Id], + }; + let setup = harness(state, async () => ({ + events: [], + researchOffer: candidate, + analysis: { + questionSetVersion: "test", + modelVersion: "fake-jev", + status: "unlinked", + passes: [], + }, + })); + let starts = 0; + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: candidate.source, + task: { + kind: "current-cost-comparison", + threadId: "storage", + options: [ + { id: s3Id, labelAtOffer: "S3" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + expect(starts).toBe(0); + + let consent = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "research", actionId: "click-research" }, + ada, + "ada-principal", + async accepted => { + starts++; + expect(accepted.brief).toBe(offer!.brief); + return { execution: "started", researchRequestId: "request-1" }; + }, + ); + expect(consent.execution).toBe("started"); + expect(starts).toBe(1); + setup.processor.stop(); + }); + + test("detects D03's generic two-option cost need without starting work before consent", async () => { + let state = seededThread("mail", "Which email provider should we use?", [ + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); + let message = entry("D03-m4", "I don't know current provider prices.", 11); + let quote = message.text; + let judgment: ResearchJudgment = { + threadId: "mail", + optionIds: [postmarkId, sesId], + quote, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + threadId: "mail", + optionIds: [postmarkId, sesId], + source: exactSource(message, quote), + }); + + let setup = harness(state, inputInterpreter(() => judgment), [message]); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, quote), + task: { + kind: "current-cost-comparison", + threadId: "mail", + options: [ + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + ], + }, + brief: `Compare current costs for Postmark and Amazon SES. Discussion context: “${quote}”`, + }); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); + + test("D03 generic provider-price uncertainty offers all three options without a pair or focus", async () => { + let state = d03Options(); + let message = entry("D03-m4-three-options", "I don't know current provider prices.", 11); + let judgment: ResearchJudgment = { + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: "all-current", + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + kind: "current-cost-concern", + threadId: "mail", + optionIds: [relayId, postmarkId, sesId], + source: exactSource(message, message.text), + }); + expect((interpretation.researchOffer as unknown as { focusOptionId?: string })?.focusOptionId) + .toBeUndefined(); + + let setup = harness(state, inputInterpreter(() => judgment), [message]); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "mail", + options: [ + { id: relayId, labelAtOffer: "SMTP relay" }, + { id: postmarkId, labelAtOffer: "Postmark" }, + { id: sesId, labelAtOffer: "Amazon SES" }, + ], + }, + }); + expect((offer?.task as unknown as { focusOptionId?: string })?.focusOptionId).toBeUndefined(); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + for (let label of ["SMTP relay", "Postmark", "Amazon SES", message.text]) { + expect(offer?.brief).toContain(label); + } + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); + + test("D04 R2 egress focuses R2 while S3 and shared disk remain in context", async () => { + let state = d04Options(); + let message = entry("D04-m5-three-options", "R2's egress could matter for thumbnails.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + let interpretation = await detectResearchOffer(state, message, judgment); + expect(interpretation.researchOffer).toMatchObject({ + kind: "current-cost-concern", + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + source: exactSource(message, message.text), + }); + expect((interpretation.researchOffer as unknown as { focusOptionId?: string })?.focusOptionId) + .toBe(r2Id); + + let setup = harness(state, inputInterpreter(() => judgment)); + let starts = 0; + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "storage", + focusOptionId: r2Id, + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: sharedDiskId, labelAtOffer: "shared disk" }, + { id: r2Id, labelAtOffer: "Cloudflare R2" }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(offer?.brief).toContain( + "Investigate current costs for Cloudflare R2; consider Amazon S3, shared disk as context.", + ); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + expect(starts).toBe(0); + let consent = await setup.processor.researchConsent( + { offerId: offer!.id, choice: "research", actionId: "D04-click-research" }, + ada, + "ada-principal", + async accepted => { + starts++; + expect(accepted.brief).toBe(offer!.brief); + return { execution: "started", researchRequestId: "D04-request" }; + }, + ); + expect(consent.execution).toBe("started"); + expect(starts).toBe(1); + setup.processor.stop(); + }); + + test("D04 focuses R2 when its current option label contains a longer question", async () => { + let state = seededThread("storage", "Where should uploaded images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "is R2 still worth comparing, or did we rule it out?" }, + ]); + let message = entry("D04-long-r2-label", "R2's egress could matter for thumbnails.", 11); + let judgment: ResearchJudgment = { + threadId: "storage", + optionIds: [s3Id, sharedDiskId, r2Id], + quote: message.text, + kind: "current-cost-concern", + scopeChoice: r2Id, + }; + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + let offer = setup.durable.state.researchOffers?.[0]; + expect(offer).toMatchObject({ + status: "offered", + source: exactSource(message, message.text), + task: { + kind: "current-cost-concern", + threadId: "storage", + focusOptionId: r2Id, + options: [ + { id: s3Id, labelAtOffer: "Amazon S3" }, + { id: sharedDiskId, labelAtOffer: "shared disk" }, + { + id: r2Id, + labelAtOffer: "is R2 still worth comparing, or did we rule it out?", + }, + ], + }, + }); + expect(offer?.brief).toBe(renderResearchTask(offer!.task!, offer!.source)); + expect(offer?.source).toEqual(exactSource(message, message.text)); + expect(setup.durable.pending).toEqual([]); + expect(setup.durable.receipts).toEqual([]); + setup.processor.stop(); + }); +}); diff --git a/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts b/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts new file mode 100644 index 00000000..83c79fdf --- /dev/null +++ b/apps/server/src/conversation-plan/research-offer-detection.test-fixtures.ts @@ -0,0 +1,348 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import type { Effect } from "./effects"; +import { applyEvent } from "./events"; +import { interpretMessage } from "./interpret"; +import type { JevAnswer, JevQuestion, JevRequest, JevResult } from "./jev"; +import { createProcessor, type Dependencies } from "./service"; + +// Whole archived detection fixture declarations; export modifiers only. +export type Member = Extract; + +export type Override = string | number | JevAnswer; + +export let ada: Member = { kind: "member", handle: "ada" }; + +export let postmarkId = "01K00000000000000000000011"; + +export let sesId = "01K00000000000000000000012"; + +export let s3Id = "01K00000000000000000000013"; + +export let r2Id = "01K00000000000000000000014"; + +export let resendId = "01K00000000000000000000015"; + +export let relayId = "01K00000000000000000000016"; + +export let sharedDiskId = "01K00000000000000000000017"; + +export let relayCopyId = "01K00000000000000000000018"; + +export let postmarkCopyId = "01K00000000000000000000019"; + +export let sesCopyId = "01K0000000000000000000001A"; + +export function entry(id: string, text: string, ts = 1): Chat.Entry { + return { id, text, ts, author: ada }; +} + +export function seededThread( + threadId: string, + question: string, + options: Array<{ id: string; text: string }>, + status: "open" | "decided" = "open", +): ConversationPlan.State { + let state = applyEvent(initialState(), { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question, + }); + for (let option of options) { + state = applyEvent(state, { + id: `add-${option.id}`, + type: "option.added", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: state.events.length + 1, + contribution: { id: option.id, text: option.text, authoring: "scribe" }, + }); + } + state = applyEvent(state, { + id: `link-${threadId}`, + type: "card.linked", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: state.events.length + 1, + questionnaireId: `card-${threadId}`, + }); + if (status === "decided") { + state = applyEvent(state, { + id: `decide-${threadId}`, + type: "decision.recorded", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: ada, + at: state.events.length + 1, + text: `Use ${options[0]!.text}`, + optionId: options[0]!.id, + explicit: true, + }); + } + return state; +} + +export function d03Options(threadId = "mail") { + return seededThread(threadId, "Which email delivery service should we use?", [ + { id: relayId, text: "SMTP relay" }, + { id: postmarkId, text: "Postmark" }, + { id: sesId, text: "Amazon SES" }, + ]); +} + +export function d04Options(threadId = "storage") { + return seededThread(threadId, "Where should uploaded images live?", [ + { id: s3Id, text: "Amazon S3" }, + { id: sharedDiskId, text: "shared disk" }, + { id: r2Id, text: "Cloudflare R2" }, + ]); +} + +export function answerResult( + questions: Record, + overrides: Record = {}, +): JevResult { + let answers: Record = {}; + for (let [key, question] of Object.entries(questions)) { + let override = overrides[key]; + if (question.type === "noul") { + answers[key] = override && typeof override === "object" + ? override + : { type: "noul", noul: typeof override === "number" ? override : 0.05 }; + } else if (question.type === "choice") { + if (override && typeof override === "object") { + answers[key] = override; + continue; + } + let choices = Object.keys(question.criteria); + let choice = typeof override === "string" && choices.includes(override) + ? override + : choices.includes("none") + ? "none" + : choices[0]!; + let remainder = (1 - 0.95) / Math.max(choices.length - 1, 1); + answers[key] = { + type: "choice", + choice, + confidence: 0.95, + probabilities: Object.fromEntries( + choices.map(item => [item, item === choice ? 0.95 : remainder]), + ), + }; + } else { + let levels = question.criteria; + answers[key] = { + type: "score", + score: 0, + confidence: 1, + legend: Object.fromEntries(levels.map((label, index) => [String(index), label])), + probabilities: Object.fromEntries( + levels.map((_, index) => [String(index), index === 0 ? 1 : 0]), + ), + }; + } + } + return { model: "fake-jev", answers, usage: { input_tokens: 1, output_tokens: 1 }, latencyMs: 1 }; +} + +export type ResearchJudgment = { + threadId: string; + optionIds: string[]; + quote: string; + kind?: "current-cost-comparison" | "current-cost-concern"; + scopeChoice?: string; + scopeAnswer?: JevAnswer; + threadAnswer?: JevAnswer; + need?: number; + triageOwned?: number; + owned?: number; + answered?: number; + quoteChoice?: string; + optionAAnswer?: JevAnswer; +}; + +export function fakeJev(judgment: ResearchJudgment) { + return async (request: JevRequest): Promise => { + let overrides: Record = {}; + if ("research_need" in request.questions) { + overrides.research_need = judgment.need ?? 0.95; + for (let key of Object.keys(request.questions)) { + if (key.endsWith("_owned_unretracted")) { + overrides[key] = judgment.triageOwned ?? 0.95; + } + } + } else if ("research_quote" in request.questions) { + let quoteQuestion = request.questions.research_quote; + let quoteChoices = quoteQuestion?.type === "choice" ? quoteQuestion.criteria : {}; + overrides.research_quote = judgment.quoteChoice + ?? Object.entries(quoteChoices).find(([, description]) => description === judgment.quote) + ?.[0] + ?? "none"; + overrides.research_thread = judgment.threadAnswer ?? judgment.threadId; + overrides.research_option_a = judgment.optionAAnswer + ?? (judgment.kind === "current-cost-concern" ? "none" : judgment.optionIds[0]); + overrides.research_option_b = judgment.kind === "current-cost-concern" + ? "none" + : judgment.optionIds[1]; + overrides.research_kind = judgment.kind ?? "current-cost-comparison"; + let quoteKey = overrides.research_quote; + let scopeKey = typeof quoteKey === "string" ? `research_${quoteKey}_scope` : ""; + if (judgment.kind === "current-cost-concern" && scopeKey in request.questions) { + overrides[scopeKey] = judgment.scopeAnswer ?? judgment.scopeChoice ?? "all-current"; + } + let ownerQuestion = typeof overrides.research_quote === "string" + ? `research_${overrides.research_quote}_owned` + : "research_q0_owned"; + overrides[ownerQuestion] = judgment.owned ?? 0.95; + let answeredQuestion = typeof overrides.research_quote === "string" + ? `research_${overrides.research_quote}_answered` + : "research_q0_answered"; + overrides[answeredQuestion] = judgment.answered ?? 0.05; + } + return answerResult(request.questions, overrides); + }; +} + +export function inputInterpreter(judgmentFor: (message: Chat.Entry) => ResearchJudgment) { + return (input: Parameters>[0]) => + interpretMessage({ ...input, ask: fakeJev(judgmentFor(input.message)) }); +} + +export function detectResearchOffer( + state: ConversationPlan.State, + message: Chat.Entry, + judgment: ResearchJudgment, +) { + return interpretMessage({ + channelId: "channel", + message, + recent: [], + state, + ask: fakeJev(judgment), + }); +} + +export function harness( + state: ConversationPlan.State, + interpret: NonNullable, + entries: Chat.Entry[] = [], +) { + let plan = { + id: "channel", + chat: { entries: [...entries] }, + conversationPlan: state, + conversationPlanRetries: [], + conversationPlanEffects: [] as string[], + conversationPlanPendingEffects: [] as Effect[], + pendingCardActions: [], + records: new Map(), + } as unknown as Dependencies["plan"]; + let durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + pending: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + }); + let publications: ConversationPlan.State[] = []; + let errors: unknown[] = []; + let persistCount = 0; + let failAt: number | undefined; + let tail = Promise.resolve(); + let dependencies: Dependencies = { + plan, + exclusive: action => { + let operation = tail.then(action, action); + tail = operation.then(() => {}, () => {}); + return operation; + }, + persist: async () => { + persistCount++; + if (persistCount === failAt) throw new Error("storage failed"); + durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + pending: plan.conversationPlanPendingEffects, + receipts: plan.conversationPlanEffects, + }); + }, + publish: next => { + expect(durable.state).toEqual(next); + publications.push(structuredClone(next)); + }, + active: () => true, + interpret, + onError: error => errors.push(error), + }; + return { + plan, + dependencies, + processor: createProcessor(dependencies), + publications, + errors, + get durable() { + return durable; + }, + get persistCount() { + return persistCount; + }, + failPersistenceAfter(offset: number) { + failAt = persistCount + offset; + }, + }; +} + +export async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 400; attempt++) { + if (condition()) return; + await Bun.sleep(1); + } + throw new Error("processor did not reach the expected state"); +} + +export async function processMessage( + setup: ReturnType, + message: Chat.Entry, +): Promise { + await setup.processor.accept(message); + setup.processor.afterMessage(); + await waitFor(() => + setup.errors.length > 0 || setup.durable.state.analysis.some( + item => item.messageId === message.id, + ) + ); +} + +export function exactSource(message: Chat.Entry, quote: string): ConversationPlan.ResearchSource { + let start = message.text.indexOf(quote); + if (start < 0) throw new Error("quote missing from synthetic message"); + return { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote, + start, + end: start + quote.length, + }; +} + +export async function expectNoDurableOffer( + state: ConversationPlan.State, + message: Chat.Entry, + judgment: ResearchJudgment, +): Promise { + let setup = harness(state, inputInterpreter(() => judgment)); + await processMessage(setup, message); + expect(setup.durable.state.researchOffers ?? []).toEqual([]); + expect(setup.durable.state.analysis.some(item => item.messageId === message.id)).toBe(true); + setup.processor.stop(); +} diff --git a/apps/server/src/conversation-plan/runtime-acceptance.test.ts b/apps/server/src/conversation-plan/runtime-acceptance.test.ts new file mode 100644 index 00000000..793e142f --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-acceptance.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +test("runtime withholds chat acknowledgement and publication until the transcript and queue commit", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let started = false; + let release!: () => void; + let gate = new Promise(resolve => release = resolve); + let original = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + started = true; + await gate; + return original(input); + }; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + interpret: async () => ({ + events: [], + analysis: { + status: "unlinked", + questionSetVersion: "test", + modelVersion: "test", + passes: [], + }, + }), + }); + let replies: string[] = []; + let socket = { + data: { handle: "test", principalId: "U_test" }, + send(value: string) { + replies.push(value); + }, + } as unknown as Socket; + let send: Promise | undefined; + try { + await runtime.attach(room, plan, false); + let context = runtime.bind( + { + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: { agent: false } as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room, + ); + send = Chat.send(context, socket, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "room", + text: "Discuss authentication", + ts: 0, + }); + await until(() => started); + expect(replies).toEqual([]); + expect(opened.broadcasts).toEqual([]); + let before = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(before.sidecar ?? before.snapshot!.sidecar).toMatchObject({ transcript: [] }); + release(); + await send; + expect(replies.map(value => JSON.parse(value).kind)).toContain("chat:send"); + let after = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(after.sidecar ?? after.snapshot!.sidecar).toMatchObject({ + transcript: [{ text: "Discuss authentication" }], + conversationPlan: { queue: [{ status: "pending" }] }, + }); + } finally { + release(); + await send; + let stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-card.test.ts b/apps/server/src/conversation-plan/runtime-card.test.ts new file mode 100644 index 00000000..111ab312 --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-card.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +// Offline synthetic model answers exercise the production interpreter and card consumers. +test("runtime saves a room message, projects a durable decision card, and reopens without duplicating it", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let errors: unknown[] = []; + let config = { agent: false, conversationPlan: true }; + let runtime = createConversationRuntime({ + config, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + interpret: input => + interpretMessage({ + ...input, + ask: async request => + mockResult(request.questions, { + new_question: 0.95, + enough_purpose: 0.95, + act: "question", + thread_target: "new", + c0_role: "question", + c0_thread: "new", + c0_duplicate: 0.05, + }), + }), + }); + let context = runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: config as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room); + let socket = { data: { handle: "test", principalId: "U_test" }, send() {} } as unknown as Socket; + let stopped: Promise | undefined; + try { + await runtime.attach(room, plan, false); + runtime.bind(context); + await Chat.send(context, socket, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "room", + text: "Which authentication system should we use?", + ts: 0, + }); + await until(() => + plan.conversationPlan.threads[0]?.questionnaireId !== undefined + && plan.conversationPlanPendingEffects.length === 0 + ); + await runtime.jobs(plan)?.idle(); + let thread = plan.conversationPlan.threads[0]!; + let card = plan.records.get(thread.questionnaireId!)!; + expect(card.origin).toBe("conversation"); + expect(card.threadId).toBe(thread.id); + expect(card.status).toBe("open"); + expect(Plan.source(plan)).toContain(thread.questionnaireId!); + let loaded = (await opened.storage.collaboration.load(plan.id, opened.now))!; + let sidecar = (loaded.sidecar ?? loaded.snapshot!.sidecar) as unknown as { + transcript: typeof plan.chat.entries; + conversationPlan: typeof plan.conversationPlan; + conversationPlanEffects: string[]; + }; + expect(sidecar.transcript[0]?.text).toBe("Which authentication system should we use?"); + expect(sidecar.conversationPlan.threads[0]?.questionnaireId).toBe(card.id); + expect(sidecar.conversationPlanEffects).toContain(`insert:${thread.id}`); + expect(sidecar.conversationPlan.analysis[0]?.status).toBe("applied"); + stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + room.plan = plan; + await runtime.attach(room, plan, false); + await Bun.sleep(10); + expect(plan.records.size).toBe(1); + expect(plan.conversationPlan.threads[0]?.questionnaireId).toBe(card.id); + expect(plan.conversationPlanPendingEffects).toEqual([]); + expect(errors).toEqual([]); + } finally { + stopped = runtime.stop(plan); + await stopped; + await Plan.close(plan); + } +}); + +test("disabled and archived runtime cannot accept a queued conversation mutation", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let disabled = createConversationRuntime({ + config: { agent: false }, + server: () => opened.server, + unavailable: () => false, + }); + let archived = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + }); + try { + await disabled.attach(room, plan, false); + expect(disabled.processor(plan)).toBeUndefined(); + await archived.attach(room, plan, true); + expect(archived.jobs(plan)).toBeUndefined(); + await expect( + archived.processor(plan)!.accept({ + id: "blocked", + author: { kind: "member", handle: "test" }, + text: "Which approach?", + ts: 0, + }), + ).rejects.toThrow("unavailable"); + expect(plan.chat.entries).toEqual([]); + expect(plan.conversationPlan.queue).toEqual([]); + } finally { + await archived.stop(plan); + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-lifecycle.test.ts b/apps/server/src/conversation-plan/runtime-lifecycle.test.ts new file mode 100644 index 00000000..c274e2ff --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-lifecycle.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { applyInference, initialState } from "./domain"; +import { createConversationRuntime } from "./runtime"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; + +/** Two replay-valid openings exercise real card consumers, with the first card commit held. */ +async function heldInsert() { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + plan.conversationPlan = initialState(); + for (let index = 1; index <= 2; index++) { + let entry = { + id: `m${index}`, + author: { kind: "member" as const, handle: "test" }, + text: `Which approach ${index}?`, + ts: index, + }; + plan.chat.entries.push(entry); + plan.conversationPlan = applyInference(plan.conversationPlan, { + id: `open${index}`, + type: "thread.opened", + threadId: `t${index}`, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: index, + question: entry.text, + source: { + messageId: entry.id, + author: entry.author, + quote: entry.text, + start: 0, + end: entry.text.length, + role: "question", + }, + }, entry); + } + plan.conversationPlanPendingEffects = plan.conversationPlan.threads.map((thread, index) => ({ + key: `insert:${thread.id}`, + kind: "insert-card", + threadId: thread.id, + header: "Decision", + question: thread.question, + options: [], + trigger: `m${index + 1}`, + })); + await Plan.persist(plan); + let entered = false; + let closed = false; + let commitsAfterClose = 0; + let publicationsAfterClose = 0; + let release!: () => void; + let gate = new Promise(resolve => release = resolve); + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let questions = (input.sidecar as { questions?: unknown[] } | undefined)?.questions; + if (!entered && questions?.length === 1) { + entered = true; + await gate; + } + if (closed) commitsAfterClose++; + return commit(input); + }; + let publish = opened.server.publish.bind(opened.server); + opened.server.publish = (...args) => { + if (closed) publicationsAfterClose++; + return publish(...args); + }; + let errors: unknown[] = []; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + }); + await runtime.attach(room, plan, false); + await until(() => entered); + return { + ...opened, + room, + runtime, + release, + errors, + closed: () => closed = true, + late: () => ({ commits: commitsAfterClose, publications: publicationsAfterClose }), + }; +} + +test("runtime stop drains a held real card insertion before document close", async () => { + let fixture = await heldInsert(); + let stopped = false; + let stopping = fixture.runtime.stop(fixture.plan).then(() => stopped = true); + try { + await Bun.sleep(10); + expect(stopped).toBe(false); + fixture.release(); + await stopping; + await Plan.close(fixture.plan); + fixture.closed(); + await Bun.sleep(10); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.late()).toEqual({ commits: 0, publications: 0 }); + let restored = await Plan.open(fixture.channel.id, fixture.backend, fixture.server); + try { + expect(restored.records.size).toBe(2); + expect(restored.conversationPlanPendingEffects).toHaveLength(2); + } finally { + await Plan.close(restored); + } + } finally { + fixture.release(); + await stopping; + if (!fixture.plan.persistence.closing) await Plan.close(fixture.plan); + } +}); + +test("replacement attachment waits for the old real effect drain and recovers without duplicate cards", async () => { + let fixture = await heldInsert(); + let previous = fixture.runtime.processor(fixture.plan); + let attached = false; + let attaching = Promise.resolve(fixture.runtime.attach(fixture.room, fixture.plan, false)) + .then(() => attached = true); + try { + await Bun.sleep(10); + expect(attached).toBe(false); + fixture.release(); + await attaching; + expect(fixture.runtime.processor(fixture.plan)).not.toBe(previous); + await until(() => fixture.plan.conversationPlanPendingEffects.length === 0); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.plan.conversationPlan.threads.every(thread => !!thread.questionnaireId)).toBe( + true, + ); + expect(fixture.broadcasts.filter(frame => frame.kind === "question:asked")).toHaveLength(2); + expect(fixture.errors).toEqual([]); + } finally { + fixture.release(); + await attaching; + await fixture.runtime.stop(fixture.plan); + await Plan.close(fixture.plan); + } +}); + +test("overlapping stops and replacement attachment share the held real effect drain", async () => { + let fixture = await heldInsert(); + let previous = fixture.runtime.processor(fixture.plan); + let firstDone = false; + let secondDone = false; + let attached = false; + let first = fixture.runtime.stop(fixture.plan); + let second = fixture.runtime.stop(fixture.plan); + let firstSettled = first.then(() => firstDone = true); + let secondSettled = second.then(() => secondDone = true); + let attaching = fixture.runtime.attach(fixture.room, fixture.plan, false) + .then(() => attached = true); + try { + await Bun.sleep(10); + expect(firstDone).toBe(false); + expect(secondDone).toBe(false); + expect(attached).toBe(false); + expect(second).toBe(first); + fixture.release(); + await Promise.all([firstSettled, secondSettled, attaching]); + expect(fixture.runtime.processor(fixture.plan)).not.toBe(previous); + await until(() => fixture.plan.conversationPlanPendingEffects.length === 0); + expect(fixture.plan.records.size).toBe(2); + expect(fixture.broadcasts.filter(frame => frame.kind === "question:asked")).toHaveLength(2); + expect(fixture.errors).toEqual([]); + } finally { + fixture.release(); + await Promise.all([firstSettled, secondSettled, attaching]); + await fixture.runtime.stop(fixture.plan); + await Plan.close(fixture.plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime-save.test.ts b/apps/server/src/conversation-plan/runtime-save.test.ts new file mode 100644 index 00000000..bbda95ac --- /dev/null +++ b/apps/server/src/conversation-plan/runtime-save.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "bun:test"; +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { openPlan } from "../testing/plan"; +import { createConversationRuntime } from "./runtime"; +import { interpretMessage } from "./interpret"; +import { mockResult } from "./interpret.test-fixtures"; +import { until } from "./service.test-fixtures"; +import type { Room } from "../rooms"; +import type { Socket } from "../wire"; + +function noop(): void {} + +test("a live conversation suggests a linked human option, and human Save commits before acknowledgement and survives reopening", async () => { + let opened = await openPlan(); + let plan = opened.plan; + let room: Room = { id: plan.id, plan, members: new Map() }; + let errors: unknown[] = []; + let replies: Array> = []; + let socket = { + data: { handle: "ana", principalId: "U_ana", client: "client", room: plan.id }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + let optionId = ""; + let runtime = createConversationRuntime({ + config: { agent: false, conversationPlan: true }, + server: () => opened.server, + unavailable: () => false, + onError: error => errors.push(error), + interpret: input => + interpretMessage({ + ...input, + ask: async request => + mockResult( + request.questions, + optionId + ? { + act: "commitment", + explicit_resolution: 0.98, + thread_target: input.state.threads[0]!.id, + c0_role: "resolution", + c0_thread: input.state.threads[0]!.id, + c0_explicit_resolution: 0.98, + c0_chosen_option: optionId, + } + : { + new_question: 0.95, + enough_purpose: 0.95, + act: "question", + thread_target: "new", + c0_role: "question", + c0_thread: "new", + c0_duplicate: 0.05, + }, + ), + }), + }); + let release = noop; + let submitting: Promise | undefined; + try { + await runtime.attach(room, plan, false); + let context = runtime.bind({ + chat: plan.chat, + plan, + server: opened.server, + room: plan.id, + config: { agent: false } as Chat.Room["config"], + persist: () => Plan.persist(plan), + } as Chat.Room); + await Chat.send(context, socket, { + kind: "chat:send", + rid: "opening", + requestId: crypto.randomUUID(), + to: "room", + text: "Which authentication system should we use?", + ts: 0, + }); + await until(() => + !!plan.conversationPlan.threads[0]?.questionnaireId + && plan.conversationPlanPendingEffects.length === 0 + ); + let thread = plan.conversationPlan.threads[0]!; + let id = thread.questionnaireId!; + await Questions.addOption(plan, opened.server, plan.id, socket, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + rid: "add", + ts: 0, + id, + label: "Passkeys", + }); + let added = replies.find(frame => frame.rid === "add")!; + expect(added.ok).toBe(true); + optionId = added.option.id; + await Chat.send(context, socket, { + kind: "chat:send", + rid: "choice", + requestId: crypto.randomUUID(), + to: "room", + text: "Let's go with Passkeys.", + ts: 0, + }); + await until(() => + Store.get(plan.questions, id)?.suggested?.optionId === optionId + && plan.conversationPlanPendingEffects.length === 0 + ); + expect(plan.records.get(id)?.status).toBe("open"); + expect(plan.conversationPlan.threads[0]?.decision).toBeUndefined(); + expect(plan.chat.entries.some(entry => entry.decision?.kind === "prompt")).toBe(true); + let settled = false; + let entered = false; + let gate = new Promise(resolve => release = resolve); + let commit = opened.storage.collaboration.commit; + opened.storage.collaboration.commit = async input => { + let records = (input.sidecar as { questions?: { status: string }[] })?.questions; + if (!entered && records?.some(record => record.status === "answered")) { + entered = true; + await gate; + } + return commit(input); + }; + let revision = Store.get(plan.questions, id)!.revision; + submitting = Questions.submit(plan, opened.server, plan.id, socket, { + kind: "question:submit", + rid: "save", + ts: 0, + id, + revision, + suggestedOptionId: optionId, + }).then(() => { + settled = true; + }); + await until(() => entered); + expect(settled).toBe(false); + expect(replies.some(frame => frame.rid === "save")).toBe(false); + expect(plan.records.get(id)?.status).toBe("open"); + let before = (await opened.storage.collaboration.load(plan.id, opened.now))!; + expect(before.sidecar ?? before.snapshot!.sidecar).toMatchObject({ + questions: [{ id, status: "open" }], + }); + release(); + await submitting; + expect(replies.find(frame => frame.rid === "save")?.ok).toBe(true); + await until(() => + plan.conversationPlan.threads[0]?.decision?.actor.kind === "member" + && plan.pendingCardActions.length === 0 + && plan.conversationPlanPendingEffects.length === 0 + ); + expect(plan.records.get(id)).toMatchObject({ + status: "answered", + owner: "ana", + choices: [optionId], + }); + expect(plan.conversationPlan.threads[0]?.decision).toMatchObject({ + actor: { kind: "member", handle: "ana" }, + text: "Passkeys", + }); + expect(errors).toEqual([]); + await runtime.stop(plan); + await Plan.close(plan); + plan = await Plan.open(opened.channel.id, opened.backend, opened.server); + room.plan = plan; + await runtime.attach(room, plan, false); + await runtime.processor(plan)?.idle(); + expect(plan.records.get(id)).toMatchObject({ + status: "answered", + owner: "ana", + choices: [optionId], + }); + expect(plan.conversationPlan.threads[0]?.decision).toMatchObject({ + actor: { kind: "member", handle: "ana" }, + text: "Passkeys", + }); + expect(plan.pendingCardActions).toEqual([]); + expect(plan.records.size).toBe(1); + } finally { + release(); + await submitting; + await runtime.stop(plan); + await Plan.close(plan); + } +}); diff --git a/apps/server/src/conversation-plan/runtime.ts b/apps/server/src/conversation-plan/runtime.ts new file mode 100644 index 00000000..63c120b0 --- /dev/null +++ b/apps/server/src/conversation-plan/runtime.ts @@ -0,0 +1,187 @@ +/** Connects durable conversation processing to the current authenticated Chat runtime. */ +import * as Chat from "../chat/service"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Room from "../plan/room"; +import { broadcast } from "../wire"; +import { cardEffects, mirrorCard, wakeCardMirror } from "./cards"; +import { createJobContexts } from "./job-context"; +import { PROMPT_FOR } from "./job-prompts"; +import { createPlannerJobs, interruptPlannerJobs } from "./planner-jobs"; +import { createProcessor, type Dependencies, type Processor } from "./service"; +import { interpretMessage } from "./interpret"; +import { askJev } from "./jev"; +import type { Config } from "../config"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type * as Rooms from "../rooms"; + +type RuntimeDeps = { + config: Pick< + Config, + "agent" | "conversationPlan" | "conversationPlanModel" | "conversationPlanTimeoutMs" + >; + server: () => Server; + unavailable: (id: string) => boolean; + interpret?: Dependencies["interpret"]; + onError?: (error: unknown) => void; +}; + +export function createConversationRuntime(deps: RuntimeDeps) { + let processors = new WeakMap(); + let jobs = new WeakMap>(); + let listeners = new WeakMap void>(); + let contexts = createJobContexts(); + let mirrors = new WeakMap>(); + let stopping = new WeakMap>(); + let report = deps.onError ?? ((error: unknown) => console.error("[conversation-plan]", error)); + + function stop(opened: Plan.Plan): Promise { + let existing = stopping.get(opened); + if (existing) return existing; + let processor = processors.get(opened); + processor?.stop(); + processors.delete(opened); + let coordinator = jobs.get(opened); + coordinator?.stop(); + jobs.delete(opened); + listeners.get(opened)?.(); + listeners.delete(opened); + contexts.clear(opened); + let mirroring = mirrors.get(opened); + mirrors.delete(opened); + let draining = Promise.all([processor?.idle(), coordinator?.idle(), mirroring]) + .then(() => {}) + .finally(() => { + if (stopping.get(opened) === draining) stopping.delete(opened); + }); + stopping.set(opened, draining); + return draining; + } + + function wake(opened: Plan.Plan): void { + let processor = processors.get(opened); + if (!processor) return; + if (!deps.unavailable(opened.id)) jobs.get(opened)?.wake(); + mirrors.set(opened, wakeCardMirror(opened, processor).catch(report)); + } + + async function attach(room: Rooms.Room, opened: Plan.Plan, archived: boolean): Promise { + if (!deps.config.conversationPlan) return; + await stop(opened); + let server = deps.server(); + let active = () => + room.plan === opened && !room.closing && !archived + && !deps.unavailable(room.id); + let announcer: Chat.Announcer = { chat: opened.chat, plan: opened, server, room: room.id }; + let processor: Processor; + let coordinator = archived || deps.unavailable(room.id) ? undefined : createPlannerJobs({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened), + runner: async (job, prompt, signal) => { + if (!active()) return { status: "skipped", reason: "The document is unavailable." }; + if (!deps.config.agent) { + return { status: "skipped", reason: "The Planner is off (AGENT=off)." }; + } + let claim = contexts.claim(opened, job); + return claim + ? Chat.job(claim.context, job, prompt, claim.claimantSessionId, signal) + : { + status: "skipped", + reason: "No signed-in member is available to supply the Planner credential.", + }; + }, + prompt: job => PROMPT_FOR[job.kind]?.(opened, job), + headingAllowed: () => Room.headingAllowed(opened.document), + publishJobs: current => + broadcast(server, room.id, { kind: "conversation-plan:jobs", ts: 0, jobs: current }), + publishMeta: id => Questions.announce(opened, server, room.id, id), + activity: async (text, questionnaireId, label) => { + await Chat.noticeExclusive(announcer, { + text, + decision: { questionnaireId, kind: "activity", ...(label ? { label } : {}) }, + }); + }, + onCapacityAvailable: () => processor.wake(), + onError: report, + }); + if (coordinator) jobs.set(opened, coordinator); + processor = createProcessor({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened), + publish: state => + broadcast(server, room.id, { kind: "conversation-plan:changed", ts: 0, state }), + active, + interpret: deps.interpret ?? ((input, signal) => + interpretMessage({ + ...input, + ask: request => + askJev(request, { + model: deps.config.conversationPlanModel, + timeoutMs: deps.config.conversationPlanTimeoutMs, + signal, + }), + })), + onError: report, + }); + processors.set(opened, processor); + processor.setEffects( + cardEffects( + opened, + server, + room.id, + processor, + coordinator + ? (intent, key) => coordinator.enqueue(intent, key) + : undefined, + announcer, + ), + ); + let recover = () => { + processor.wake(); + mirrors.set(opened, mirrorCard(opened, processor).catch(report)); + }; + listeners.set(opened, Questions.listen(opened, recover)); + recover(); + } + + function bind(context: Chat.Room): Chat.Room { + let opened = context.plan; + let processor = processors.get(opened); + if (processor) { + context.commitRoomMessage = entry => + contexts.accept(opened, entry.id, context, () => processor.accept(entry)); + context.roomMessagePublished = () => { + processor.afterMessage(); + wake(opened); + }; + } + return context; + } + + function interrupt(opened: Plan.Plan): Promise { + let server = deps.server(); + return interruptPlannerJobs({ + plan: opened, + exclusive: action => Plan.exclusive(opened, action), + persist: () => Plan.persistExclusive(opened, true), + publishJobs: current => + broadcast(server, opened.id, { kind: "conversation-plan:jobs", ts: 0, jobs: current }), + publishMeta: id => Questions.announce(opened, server, opened.id, id), + onError: report, + }); + } + + return { + attach, + bind, + wake, + stop, + interrupt, + contexts, + processor: (opened: Plan.Plan) => processors.get(opened), + jobs: (opened: Plan.Plan) => jobs.get(opened), + }; +} diff --git a/apps/server/src/conversation-plan/scripted-harness.test-driver.ts b/apps/server/src/conversation-plan/scripted-harness.test-driver.ts new file mode 100644 index 00000000..f189d04c --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-harness.test-driver.ts @@ -0,0 +1,200 @@ +import { held, readScript, substitute } from "./scripted-script"; + +import type { HarnessV1, HarnessV1StreamPart } from "@ai-sdk/harness"; +import type { Plan } from "../plan/service"; + +type Result = { toolCallId: string; toolName: string; output: unknown; isError?: boolean }; +type ToolResult = Extract; + +/** The SDK executes tools; this test driver supplies only the scripted model side. */ +export function createScriptedHarness( + dir: string, + plan: Plan, + observe: { onResult?: (result: Result) => Promise | void } = {}, +) { + let tools: string[][] = []; + let calls: Array<{ toolCallId: string; toolName: string; input: unknown }> = []; + let results: Result[] = []; + let entered = Promise.withResolvers(); + let released = Promise.withResolvers(); + let starts = 0; + let destroyed = 0; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "current-scripted-jobs", + builtinTools: {}, + async doStart(start) { + let turns = new Set<() => void>(); + let tasks = new Set>(); + let destruction: Promise | undefined; + let track = (task: Promise) => { + tasks.add(task); + void task.then(() => tasks.delete(task), () => tasks.delete(task)); + }; + let drain = async () => { + while (tasks.size) await Promise.allSettled(tasks); + }; + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + if (destruction) throw new Error("scripted Harness session is destroyed"); + starts++; + tools.push(options.tools.map(spec => spec.name)); + let job = plan.chat.job; + let turn = plan.chat.turn; + let done = Promise.withResolvers(); + let pending = new Map void; + }>(); + let submissions = new Set>(); + let stop = new AbortController(); + let finished = false; + let current = () => + !finished && !options.abortSignal?.aborted && !plan.chat.closed + && plan.chat.job === job && plan.chat.turn === turn; + let finish = () => { + if (finished) return; + finished = true; + stop.abort(); + options.abortSignal?.removeEventListener("abort", finish); + turns.delete(finish); + for (let entry of pending.values()) entry.resolve(); + pending.clear(); + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + }; + let fail = (error: unknown) => { + if (finished) return; + options.emit({ type: "error", error }); + finish(); + }; + turns.add(finish); + options.abortSignal?.addEventListener("abort", finish, { once: true }); + if (options.abortSignal?.aborted) finish(); + let driving = (async () => { + try { + if (finished) return; + if (!job || !turn) throw new Error("scripted Harness requires a current job turn"); + let script = await readScript(dir, job.kind); + if (!current()) return finish(); + entered.resolve(); + let gate = await held(dir, job.kind, stop.signal); + if (gate === "timed-out") throw new Error("Scripted Planner job hold timed out."); + if (gate !== "released" || !current()) return finish(); + released.resolve(); + for (let call of script) { + if (!current()) return finish(); + let toolCallId = crypto.randomUUID(); + let input = substitute(call.args, plan.revision, job.target); + let returned = Promise.withResolvers(); + pending.set(toolCallId, { name: call.tool, resolve: returned.resolve }); + calls.push({ toolCallId, toolName: call.tool, input }); + options.emit({ + type: "tool-call", + toolCallId, + toolName: call.tool, + input: JSON.stringify(input), + }); + await returned.promise; + } + finish(); + } catch (error) { + fail(error); + } finally { + await Promise.allSettled(submissions); + done.resolve(); + } + })(); + track(driving); + return { + done: done.promise, + submitToolResult(value) { + let submission = (async () => { + let entry = pending.get(value.toolCallId); + if (!entry || !current()) return finish(); + try { + let result: Result = { + toolCallId: value.toolCallId, + toolName: entry.name, + output: value.output, + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }; + results.push(result); + await observe.onResult?.(result); + if (!current()) return finish(); + options.emit({ + type: "tool-result", + toolCallId: value.toolCallId, + toolName: entry.name, + result: value.output as ToolResult["result"], + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }); + } catch (error) { + fail(error); + } finally { + pending.delete(value.toolCallId); + entry.resolve(); + } + })(); + submissions.add(submission); + track(submission); + void submission.then( + () => submissions.delete(submission), + () => submissions.delete(submission), + ); + return submission; + }, + }; + }, + doDestroy() { + destruction ??= (async () => { + for (let finish of turns) finish(); + await drain(); + destroyed++; + })(); + return destruction; + }, + async doCompact() { + throw new Error("scripted Harness does not support compaction"); + }, + async doContinueTurn() { + throw new Error("scripted Harness does not support continuing a turn"); + }, + async doSuspendTurn() { + throw new Error("scripted Harness does not support suspending a turn"); + }, + async doDetach() { + throw new Error("scripted Harness does not support detaching a turn"); + }, + async doStop() { + for (let finish of turns) finish(); + await drain(); + return { + type: "resume-session", + harnessId: "current-scripted-jobs", + specificationVersion: "harness-v1", + data: null, + }; + }, + }; + }, + }; + return { + fake, + tools, + calls, + results, + starts: () => starts, + destroyed: () => destroyed, + entered: entered.promise, + released: released.promise, + }; +} diff --git a/apps/server/src/conversation-plan/scripted-harness.test.ts b/apps/server/src/conversation-plan/scripted-harness.test.ts new file mode 100644 index 00000000..59e5c20f --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-harness.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { headingJob, scriptedHeading } from "../chat/job-scripted-harness.test-fixtures"; +import type { HarnessV1StreamPart } from "@ai-sdk/harness"; + +test("the scripted model contract drains an admitted result before destruction", async () => { + let h = await scriptedHeading([{ tool: "draft_heading", args: {} }]); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let controller = new AbortController(); + let session: Awaited> | undefined; + let observerFinished = false; + let destroyed = false; + let done = false; + let parts: HarnessV1StreamPart[] = []; + try { + h.opened.plan.chat.job = headingJob(); + h.opened.plan.chat.turn = { + id: "contract-turn", + handle: "chopin", + started: 1, + responded: false, + }; + h.onResult(async () => { + entered.resolve(); + await release.promise; + observerFinished = true; + }); + session = await h.driver.fake.doStart({ sessionId: "contract-session" } as never); + let prompt = await session.doPromptTurn({ + tools: [], + abortSignal: controller.signal, + emit: (part: HarnessV1StreamPart) => parts.push(part), + } as never); + void prompt.done.then(() => { + done = true; + }); + await h.driver.released; + let call = h.driver.calls[0]; + if (!call) throw new Error("missing admitted scripted tool call"); + let submitted = prompt.submitToolResult({ toolCallId: call.toolCallId, output: "{}" }); + await entered.promise; + controller.abort(); + let destroying = session.doDestroy(); + void destroying.then(() => { + destroyed = true; + }); + await Bun.sleep(10); + expect(observerFinished).toBe(false); + expect(done).toBe(false); + expect(destroyed).toBe(false); + expect(h.driver.destroyed()).toBe(0); + release.resolve(); + await submitted; + await destroying; + await prompt.done; + expect(observerFinished).toBe(true); + expect(done).toBe(true); + expect(destroyed).toBe(true); + expect(h.driver.destroyed()).toBe(1); + expect(h.driver.calls).toHaveLength(1); + expect(parts.filter(part => part.type === "tool-result")).toEqual([]); + await session.doDestroy(); + expect(h.driver.destroyed()).toBe(1); + } finally { + controller.abort(); + release.resolve(); + await session?.doDestroy(); + await h.close(); + } +}); + +test("the scripted model stop cancels its held gate without aborting the caller signal", async () => { + let h = await scriptedHeading([], true); + let controller = new AbortController(); + let session: Awaited> | undefined; + let escape: ReturnType | undefined; + try { + let job = headingJob(); + let turn = { id: "stop-turn", handle: "chopin", started: 1, responded: false }; + h.opened.plan.chat.job = job; + h.opened.plan.chat.turn = turn; + session = await h.driver.fake.doStart({ sessionId: "stop-session" } as never); + let prompt = await session.doPromptTurn({ + tools: [], + abortSignal: controller.signal, + emit: (_part: HarnessV1StreamPart) => {}, + } as never); + await h.driver.entered; + escape = setTimeout(() => controller.abort(), 500); + let stopped = await session.doStop(); + expect(controller.signal.aborted).toBe(false); + clearTimeout(escape); + await prompt.done; + expect(stopped).toMatchObject({ type: "resume-session", harnessId: h.driver.fake.harnessId }); + expect(h.driver.calls).toEqual([]); + expect(h.driver.results).toEqual([]); + expect(h.opened.plan.chat.job).toBe(job); + expect(h.opened.plan.chat.turn).toBe(turn); + expect(await h.savedSource()).toBe(""); + await session.doDestroy(); + expect(h.driver.destroyed()).toBe(1); + } finally { + clearTimeout(escape); + controller.abort(); + await session?.doDestroy(); + await h.close(); + } +}); diff --git a/apps/server/src/conversation-plan/scripted-script.test.ts b/apps/server/src/conversation-plan/scripted-script.test.ts new file mode 100644 index 00000000..094b2dea --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-script.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { held, readScript, substitute } from "./scripted-script"; + +let directories: string[] = []; +afterEach(async () => { + for (let directory of directories) await rm(directory, { recursive: true, force: true }); + directories = []; +}); + +async function directory(): Promise { + let path = await mkdtemp(join(tmpdir(), "planner-script-loader-")); + directories.push(path); + return path; +} + +async function script(value: unknown): Promise { + let path = await directory(); + await writeFile(join(path, "heading.json"), JSON.stringify(value)); + return path; +} + +test("readScript preserves exact calls through the sixteen-call limit", async () => { + let calls = Array.from({ length: 16 }, (_, index) => ({ + tool: "draft_heading", + args: { revision: "$revision", title: `Heading ${index}`, goal: "$target" }, + })); + let path = await script(calls); + expect(await readScript(path, "heading")).toEqual(calls); +}); + +test.each([ + ["seventeen calls", Array.from({ length: 17 }, () => ({ tool: "read_plan", args: {} }))], + ["extra call field", [{ tool: "read_plan", args: {}, extra: true }]], + ["array args", [{ tool: "read_plan", args: [] }]], + ["null args", [{ tool: "read_plan", args: null }]], + ["invalid tool", [{ tool: "read-plan", args: {} }]], +])("readScript rejects %s", async (_name, value) => { + let path = await script(value); + await expect(readScript(path, "heading")).rejects.toThrow("calls are invalid"); +}); + +test("readScript retains the original string length bound rather than UTF-8 byte count", async () => { + let calls = [{ tool: "draft_heading", args: { title: "é".repeat(40_000) } }]; + let path = await script(calls); + expect(await readScript(path, "heading")).toEqual(calls); + await writeFile( + join(path, "heading.json"), + JSON.stringify([ + { tool: "draft_heading", args: { title: "x".repeat(64 * 1024) } }, + ]), + ); + await expect(readScript(path, "heading")).rejects.toThrow("scripted Planner job is too large"); +}); + +test("readScript exposes the original missing-file and malformed-JSON errors", async () => { + let path = await directory(); + await expect(readScript(path, "heading")).rejects.toMatchObject({ code: "ENOENT" }); + await writeFile(join(path, "heading.json"), "{not JSON"); + await expect(readScript(path, "heading")).rejects.toBeInstanceOf(SyntaxError); +}); + +test("substitution replaces nested markers and refuses depth beyond sixteen", () => { + expect(substitute({ revision: "$revision", values: ["$target", { keep: "literal" }] }, 7, "q1")) + .toEqual({ revision: 7, values: ["q1", { keep: "literal" }] }); + let nested: unknown = "$revision"; + for (let depth = 0; depth < 16; depth++) nested = [nested]; + expect(() => substitute(nested, 7, "q1")).not.toThrow(); + expect(() => substitute([nested], 7, "q1")).toThrow("arguments are too deep"); +}); + +test("an actual hold file respects release and abort conditions", async () => { + let path = await directory(); + await writeFile(join(path, "heading.hold"), "hold"); + await writeFile(join(path, "heading.release"), "release"); + expect(await held(path, "heading")).toBe("released"); + await rm(join(path, "heading.release")); + let controller = new AbortController(); + controller.abort(); + expect(await held(path, "heading", controller.signal)).toBe("stopped"); +}); diff --git a/apps/server/src/conversation-plan/scripted-script.ts b/apps/server/src/conversation-plan/scripted-script.ts new file mode 100644 index 00000000..c8b44ceb --- /dev/null +++ b/apps/server/src/conversation-plan/scripted-script.ts @@ -0,0 +1,79 @@ +/** Test-only script parsing and hold gates shared by deterministic Planner fixtures. */ + +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +import type { Job } from "./jobs"; + +export type Call = { tool: string; args: Record }; +export const MAX_SCRIPT_BYTES = 64 * 1024; +const MAX_CALLS = 16; + +export function reason(value: unknown): string { + return (value instanceof Error ? value.message : String(value)).trim().slice(0, 500) + || "Scripted Planner job failed."; +} + +export function substitute(value: unknown, revision: number, target: string, depth = 0): unknown { + if (depth > 16) throw new Error("scripted Planner job arguments are too deep"); + if (value === "$revision") return revision; + if (value === "$target") return target; + if (Array.isArray(value)) return value.map(item => substitute(item, revision, target, depth + 1)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(( + [key, item], + ) => [key, substitute(item, revision, target, depth + 1)]), + ); + } + return value; +} + +export function calls(value: unknown): Call[] { + if ( + !Array.isArray(value) || value.length > MAX_CALLS + || value.some(item => + !item || typeof item !== "object" || Array.isArray(item) + || Object.keys(item).some(key => key !== "tool" && key !== "args") + || typeof item.tool !== "string" || !/^[a-z_]{1,80}$/.test(item.tool) + || !item.args || typeof item.args !== "object" || Array.isArray(item.args) + ) + ) throw new Error("scripted Planner job calls are invalid"); + return value as Call[]; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export async function held( + dir: string, + kind: Job["kind"], + signal?: AbortSignal, +): Promise<"released" | "stopped" | "timed-out"> { + if (!await exists(join(dir, `${kind}.hold`))) return "released"; + let deadline = Date.now() + 30_000; + for (;;) { + if (signal?.aborted) return "stopped"; + if (Date.now() >= deadline) return "timed-out"; + if (await exists(join(dir, `${kind}.release`))) return "released"; + try { + await setTimeout(25, undefined, { signal }); + } catch { + return "stopped"; + } + } +} + +export async function readScript(dir: string, kind: Job["kind"]): Promise { + let data = await readFile(join(dir, `${kind}.json`), "utf8"); + if (data.length > MAX_SCRIPT_BYTES) throw new Error("scripted Planner job is too large"); + return calls(JSON.parse(data)); +} diff --git a/apps/server/src/conversation-plan/service-correction.test-fixtures.ts b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts new file mode 100644 index 00000000..d8d3a209 --- /dev/null +++ b/apps/server/src/conversation-plan/service-correction.test-fixtures.ts @@ -0,0 +1,169 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, completeAnalysis, enqueue, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { entry, harness, opened } from "./service.test-fixtures"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export function excerptCorrectionSetup(options: { + text?: string; + selectedText?: string; + outcome?: "review" | "ignored" | "accepted" | "missing"; + outcomeRange?: [number, number]; + threadStatus?: "open" | "discarded"; + duplicateSource?: boolean; + linked?: boolean; +} = {}) { + let setup = harness(); + let question = entry("question", "Which service should send notification emails?"); + setup.plan.chat.entries.push(question); + let state = applyInference(initialState(), opened(question), question); + let option = entry("existing-option", "Use a current SMTP service."); + setup.plan.chat.entries.push(option); + state = applyInference(state, { + id: "option:existing", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: option.ts, + source: { + messageId: option.id, + author: option.author as ConversationPlan.SourceAuthor, + quote: option.text, + start: 0, + end: option.text.length, + role: "option", + }, + contribution: { + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + text: option.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, option); + if (options.linked !== false) { + state = applyEvent(state, { + id: "card-link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "01K0N4W3B7P27CBAEC7A8C8WEA", + }); + } + let excerpt: Chat.Entry = { + ...entry( + "excerpt", + options.text ?? "By agent access I mean Copilot or bring-your-own API keys.", + ), + author: { kind: "member", handle: "alice" }, + }; + setup.plan.chat.entries.push(excerpt); + let selected = options.selectedText ?? "Copilot"; + let start = excerpt.text.indexOf(selected); + let end = start + selected.length; + if (options.duplicateSource) { + state = applyInference(state, { + id: "already-classified-excerpt", + type: "reason.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: excerpt.ts, + source: { + messageId: excerpt.id, + author: excerpt.author as ConversationPlan.SourceAuthor, + quote: selected, + start, + end, + role: "reason", + }, + contribution: { + id: "existing-excerpt-reason", + text: selected, + authoring: "quoted", + targetId: "thread-a", + }, + }, excerpt); + } + if (options.threadStatus === "discarded") { + state = applyEvent(state, { + id: "discard-thread", + type: "thread.discarded", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: excerpt.ts, + }); + } + state = enqueue(state, excerpt.id); + state = completeAnalysis(state, excerpt.id, [], excerpt, { + questionSetVersion: "conversation-plan-5", + modelVersion: "jev-test", + status: "unlinked", + passes: [], + outcomes: options.outcome === "missing" + ? [] + : [{ + start: options.outcomeRange?.[0] ?? 0, + end: options.outcomeRange?.[1] ?? excerpt.text.length, + status: options.outcome === "accepted" ? "accepted" : options.outcome ?? "review", + gate: "unclassified excerpt", + eventIds: [], + }], + }); + setup.plan.conversationPlan = state; + let cardId = state.threads[0]!.questionnaireId; + if (cardId) { + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: options.threadStatus === "discarded" ? "cancelled" : "open", + origin: "conversation", + definition: { + questions: [{ + id: "01K0N4W3B7P27CBAEC7A8C8WEC", + header: "Notifications", + question: question.text, + multiple: false, + options: [{ + id: "01K0N4W3B7P27CBAEC7A8C8WEB", + label: option.text, + description: "", + }], + }], + }, + history: [], + optionOrigins: {}, + editors: [], + }); + } + return { setup, excerpt, start, end }; +} + +export function excerptAction( + state: ConversationPlan.State, + excerpt: Chat.Entry, + start: number, + end: number, + change: Record = { contributionKind: "reason" }, +): ConversationPlan.CorrectionAction { + return { + actionId: "add-excerpt", + threadId: "thread-a", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: excerpt.id, + start, + end, + ...change, + } as unknown as ConversationPlan.CorrectionChange, + } as ConversationPlan.CorrectionAction; +} diff --git a/apps/server/src/conversation-plan/service-corrections.test.ts b/apps/server/src/conversation-plan/service-corrections.test.ts new file mode 100644 index 00000000..bacff86b --- /dev/null +++ b/apps/server/src/conversation-plan/service-corrections.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; + +import type { Effect } from "./effects"; + +import { createProcessor } from "./service"; + +import { excerptAction, excerptCorrectionSetup } from "./service-correction.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a human can add a saved subspan and persist its option effect before publication", async () => { + let { setup, excerpt, start, end } = excerptCorrectionSetup(); + let effectsAtPublish: Effect[][] = []; + let publish = setup.dependencies.publish; + setup.dependencies.publish = state => { + effectsAtPublish.push(structuredClone(setup.durable.pending)); + publish(state); + }; + let processor = createProcessor(setup.dependencies); + let action = excerptAction(setup.plan.conversationPlan, excerpt, start, end, { + contributionKind: "option", + }); + let result = await processor.correct(action, { kind: "member", handle: "bob" }); + let saved = setup.durable.state; + let event = saved.events.at(-1); + let quote = excerpt.text.slice(start, end); + expect(result).toEqual({ eventId: "human:bob:add-excerpt", revision: saved.revision }); + expect(event).toMatchObject({ + type: "option.added", + origin: "human", + actor: { kind: "member", handle: "bob" }, + source: { + messageId: excerpt.id, + author: excerpt.author, + quote, + start, + end, + role: "option", + }, + contribution: { text: quote, authoring: "quoted" }, + }); + expect(saved.threads[0]?.contributions.at(-1)).toMatchObject({ + text: quote, + actor: { kind: "member", handle: "bob" }, + sources: [{ messageId: excerpt.id, author: excerpt.author, quote, start, end }], + }); + expect(setup.durable.pending).toMatchObject([{ + kind: "add-option", + threadId: "thread-a", + optionId: expect.any(String), + label: quote, + trigger: event?.id, + }]); + expect(effectsAtPublish).toEqual([setup.durable.pending]); + expect(setup.publications).toEqual([saved]); + await expect(processor.correct(action, { kind: "member", handle: "bob" })).resolves.toEqual( + result, + ); + expect(setup.plan.conversationPlan.events.filter(item => item.id === result.eventId)) + .toHaveLength(1); + expect(setup.publications).toHaveLength(1); +}); + +test.each( + [ + { + name: "the selected range lies outside the reviewed outcome", + setup: { outcomeRange: [0, 4] as [number, number] }, + change: { contributionKind: "reason", targetOptionId: "01K0N4W3B7P27CBAEC7A8C8WEB" }, + expected: /outcome|range|review/i, + }, + { + name: "the analysis has no held outcome", + setup: { outcome: "missing" as const }, + change: { contributionKind: "reason" }, + expected: /outcome|review|held/i, + }, + { + name: "the analysis outcome has already been accepted", + setup: { outcome: "accepted" as const }, + change: { contributionKind: "reason" }, + expected: /outcome|review|held/i, + }, + { + name: "the expected thread version is stale", + setup: {}, + stale: true, + change: { contributionKind: "reason" }, + expected: /stale/i, + }, + { + name: "the destination thread is closed", + setup: { threadStatus: "discarded" as const }, + change: { contributionKind: "reason" }, + expected: /open|closed|discarded/i, + }, + { + name: "the destination has no linked decision card", + setup: { linked: false }, + change: { contributionKind: "option" }, + expected: /card.*open/i, + }, + { + name: "the target option does not exist", + setup: {}, + change: { contributionKind: "reason", targetOptionId: "missing-option" }, + expected: /target|option/i, + }, + { + name: "the source span has already been corrected", + setup: { duplicateSource: true }, + change: { contributionKind: "reason" }, + expected: /duplicate|already|source/i, + }, + { + name: "the request supplies a forged quote", + setup: {}, + change: { contributionKind: "reason", quote: "invented excerpt" }, + expected: /unknown|quote|source/i, + }, + ] as const, +)("rejects excerpt correction when $name", async item => { + let { setup, excerpt, start, end } = excerptCorrectionSetup(item.setup); + let before = structuredClone(setup.plan.conversationPlan); + let action = excerptAction(setup.plan.conversationPlan, excerpt, start, end, item.change); + if ("stale" in item && item.stale) action.expectedVersion--; + let processor = createProcessor(setup.dependencies); + await expect(processor.correct(action, { kind: "member", handle: "bob" })) + .rejects.toThrow(item.expected); + expect(setup.plan.conversationPlan).toEqual(before); + expect(setup.publications).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/service-effect-commit.test.ts b/apps/server/src/conversation-plan/service-effect-commit.test.ts new file mode 100644 index 00000000..370dfcbc --- /dev/null +++ b/apps/server/src/conversation-plan/service-effect-commit.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; + +import type { Effect } from "./effects"; + +import { createProcessor, MAX_PENDING_EFFECTS } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("analysis persists an insert intent before delivery and a new processor drains it", async () => { + let setup = harness(async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, + })); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.durable.state.queue.length === 0); + expect(setup.durable.pending).toMatchObject([{ key: "insert:thread-a" }]); + expect(setup.durable.receipts).toEqual([]); + processor.stop(); + + let restored = harness(); + restored.plan.chat.entries = structuredClone(setup.durable.entries); + restored.plan.conversationPlan = restoreState(setup.durable.state, restored.plan.chat.entries); + restored.plan.conversationPlanPendingEffects = structuredClone(setup.durable.pending); + let resumed = createProcessor(restored.dependencies); + let calls: string[] = []; + resumed.setEffects({ + target: () => ({ kind: "unlinked" }), + insertCard: async () => (calls.push("insert"), "Q"), + link: async () => void calls.push("link"), + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + report: error => restored.errors.push(error), + }); + resumed.wake(); + await until(() => restored.durable.receipts.includes("insert:thread-a")); + expect(calls).toEqual(["insert", "link"]); + expect(restored.durable.pending).toEqual([]); +}); + +test("a linked event commits a separate refine intent; missing job sink leaves it pending", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + let processor = createProcessor(setup.dependencies); + expect( + await processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "Q", + })), + ).toBe(true); + expect(setup.durable.pending).toMatchObject([{ + key: "job:refine:Q", + intent: { kind: "refine", target: "Q" }, + }]); + processor.wake(); + await Bun.sleep(2); + expect(setup.durable.pending).toHaveLength(1); + expect(setup.durable.receipts).toEqual([]); +}); + +test("mirrored card actions acknowledge FIFO with the event and roll back together", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let options = ["01K0N4W3B7P27CBAEC7A8C8WEB", "01K0N4W3B7P27CBAEC7A8C8WEC"]; + setup.plan.pendingCardActions = options.map((optionId, index) => ({ + id: `card:${cardId}:option:${optionId}`, + cardId, + threadId: "thread-a", + actor: index ? "ben" : "ana", + at: index + 1, + kind: "option-added" as const, + optionId, + label: `Option ${index}`, + origin: "human" as const, + })); + let processor = createProcessor(setup.dependencies); + let make = (index: number) => (state: ConversationPlan.State): ConversationPlan.Event => ({ + id: `card:${cardId}:option:${options[index]}`, + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: index ? "ben" : "ana" }, + at: index + 1, + contribution: { + id: options[index]!, + text: `Option ${index}`, + authoring: "human-edited", + targetId: "thread-a", + }, + }); + let first = setup.plan.pendingCardActions[0]!.id; + let second = setup.plan.pendingCardActions[1]!.id; + await expect(processor.record(make(1), second)).rejects.toThrow(/FIFO/); + setup.fail(); + await expect(processor.record(make(0), first)).rejects.toThrow("storage failed"); + expect(setup.plan.pendingCardActions).toHaveLength(2); + expect(setup.plan.conversationPlan.threads[0]!.contributions).toHaveLength(0); + expect(await processor.record(make(0), first)).toBe(true); + expect(setup.durable.pendingCards.map(item => item.id)).toEqual([second]); + expect(await processor.record(make(0), first)).toBe(false); + expect(await processor.record(make(1), second)).toBe(true); + expect(setup.durable.pendingCards).toEqual([]); + expect(setup.durable.state.threads[0]!.contributions.map(item => item.id)).toEqual(options); +}); + +test("a full effect outbox rolls back the analysis rather than dropping an insert", async () => { + let setup = harness(async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, + })); + setup.plan.conversationPlanPendingEffects = Array.from( + { length: MAX_PENDING_EFFECTS }, + (_, index): Effect => ({ + key: `job:existing:${index}`, + kind: "job", + intent: { kind: "prose", target: "document", trigger: `old-${index}` }, + }), + ); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Which auth system?")); + let published = setup.publications.length; + processor.afterMessage(); + await until(() => setup.errors.length > 0); + expect(String(setup.errors[0])).toContain("outbox is full"); + expect(setup.plan.conversationPlan.threads).toEqual([]); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.plan.conversationPlanPendingEffects).toHaveLength(MAX_PENDING_EFFECTS); + expect(setup.durable.state.threads).toEqual([]); + expect(setup.publications).toHaveLength(published + 1); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-effect-retry.test.ts b/apps/server/src/conversation-plan/service-effect-retry.test.ts new file mode 100644 index 00000000..f7f94c6b --- /dev/null +++ b/apps/server/src/conversation-plan/service-effect-retry.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; + +import { applyInference, initialState } from "./domain"; + +import type { Effect } from "./effects"; + +import { createProcessor, MAX_PENDING_EFFECTS } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("failed pre-link option stays ahead of the linked refine job", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlanPendingEffects = [ + { + key: "insert:thread-a", + kind: "insert-card", + threadId: "thread-a", + header: "Auth", + question: source.text, + options: [], + trigger: "open:seed", + }, + { + key: "option:01K0N4W3B7P27CBAEC7A8C8WEA", + kind: "add-option", + threadId: "thread-a", + optionId: "01K0N4W3B7P27CBAEC7A8C8WEA", + label: "GitHub Apps", + trigger: "add-1", + }, + ]; + let processor = createProcessor(setup.dependencies); + let failOption = true; + let calls: string[] = []; + processor.setEffects({ + target: id => { + let thread = setup.plan.conversationPlan.threads.find(item => item.id === id); + return thread?.questionnaireId + ? { kind: "open", id: thread.questionnaireId } + : { kind: "unlinked" }; + }, + insertCard: async () => (calls.push("insert"), "Q"), + link: async (_id, questionnaireId) => { + calls.push("link"); + await processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId, + })); + }, + addOption: async () => { + calls.push("option"); + if (failOption) throw new Error("option commit failed"); + }, + suggest: async () => {}, + prompt: async () => {}, + enqueueJob: async () => void calls.push("refine"), + report: error => setup.errors.push(error), + }); + await until(() => setup.errors.length > 0); + await Bun.sleep(10); + let failures = setup.errors.length; + await Bun.sleep(10); + expect(setup.errors).toHaveLength(failures); + expect(calls.slice(0, 2)).toEqual(["insert", "link"]); + expect(calls.slice(2).every(call => call === "option")).toBe(true); + expect(calls).not.toContain("refine"); + expect(setup.durable.pending.map(item => item.key)).toEqual([ + "option:01K0N4W3B7P27CBAEC7A8C8WEA", + "job:refine:Q", + ]); + expect(setup.durable.receipts).toEqual(["insert:thread-a"]); + failOption = false; + processor.wake(); + await until(() => setup.durable.receipts.includes("job:refine:Q")); + expect(calls.at(-2)).toBe("option"); + expect(calls.at(-1)).toBe("refine"); +}); + +test("legacy unlinked recovery inserts an empty card when old option IDs are unusable", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + let state = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlan = { + ...state, + threads: [{ + ...state.threads[0], + contributions: [{ + id: "classifier:historical", + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + }], + }; + let processor = createProcessor(setup.dependencies); + let seen: Array<{ options: Array<{ id: string; label: string }> }> = []; + processor.setEffects({ + target: () => ({ kind: "unlinked" }), + insertCard: async input => (seen.push({ options: input.options }), "Q"), + link: async () => {}, + addOption: async () => {}, + suggest: async () => {}, + prompt: async () => {}, + report: error => setup.errors.push(error), + }); + await until(() => setup.durable.receipts.includes("insert:thread-a")); + expect(seen).toEqual([{ options: [] }]); + expect(setup.durable.pending).toEqual([]); +}); + +test("record rolls back the linked event when its refine intent cannot fit", async () => { + let setup = harness(); + let source = entry("seed", "Which auth system?"); + setup.plan.chat.entries.push(source); + setup.plan.conversationPlan = applyInference(initialState(), opened(source), source); + setup.plan.conversationPlanPendingEffects = Array.from( + { length: MAX_PENDING_EFFECTS }, + (_, index): Effect => ({ + key: `job:existing:${index}`, + kind: "job", + intent: { kind: "prose", target: "document", trigger: `old-${index}` }, + }), + ); + let processor = createProcessor(setup.dependencies); + await expect(processor.record(state => ({ + id: "card:Q:linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1001, + questionnaireId: "Q", + }))).rejects.toThrow("outbox is full"); + expect(setup.plan.conversationPlan.threads[0]?.questionnaireId).toBeUndefined(); + expect(setup.plan.conversationPlanPendingEffects).toHaveLength(MAX_PENDING_EFFECTS); + expect(setup.publications).toEqual([]); +}); + +test("repeated purpose while jobs are deferred keeps the first heading intent", async () => { + let setup = harness(async () => ({ + events: [], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "unlinked", + passes: [{ + stage: "triage", + answers: { + enough_purpose: { type: "noul", noul: 0.95 }, + }, + }], + }, + })); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("purpose-1", "We are building a private editor.")); + await processor.accept(entry("purpose-2", "It should support collaboration.")); + processor.afterMessage(); + await until(() => setup.durable.state.queue.length === 0 || setup.errors.length > 0); + expect(setup.errors).toEqual([]); + expect(setup.durable.pending).toMatchObject([{ + key: "job:heading:document", + intent: { trigger: "purpose-1" }, + }]); + expect(setup.durable.pending).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/service-inference.test.ts b/apps/server/src/conversation-plan/service-inference.test.ts new file mode 100644 index 00000000..1c61866e --- /dev/null +++ b/apps/server/src/conversation-plan/service-inference.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("card option edits during inference discard the old resolution before publication", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed", "Which auth system?"); + let choice = entry("choice", "Let's just go with Auth0."); + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "link", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + let held = deferred(); + let calls = 0; + let setup = harness(async input => { + calls++; + if (calls > 1) return unlinked(); + expect(input.linkedCards?.get("thread-a")?.options[0]?.label).toBe("Auth0"); + await held.promise; + let source = { + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + }; + return { + events: [{ + id: "mirror:choice", + type: "option.added" as const, + threadId: "thread-a", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + source: { ...source, role: "option" as const }, + contribution: { id: optionId, text: "Auth0", authoring: "scribe" as const }, + }, { + id: "settle:choice", + type: "settle.suggested" as const, + threadId: "thread-a", + observedThreadVersion: input.state.threads[0]!.version + 1, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + source: { ...source, role: "resolution" as const }, + optionId, + }], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "applied" as const, + passes: [], + }, + }; + }); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Auth0" }] }] }, + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(choice); + processor.afterMessage(); + await until(() => calls === 1); + await setup.dependencies.exclusive(async () => { + setup.plan.records.get(cardId)!.definition.questions[0]!.options[0]!.label = "Different option"; + }); + held.resolve(); + await until(() => + calls === 2 && setup.plan.conversationPlan.analysis.some(item => item.messageId === choice.id) + ); + expect(setup.plan.conversationPlan.events.some(item => item.id === "mirror:choice")).toBe(false); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + processor.stop(); +}); + +test("a scoped spike proposal commits before publication without deciding the card", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed-spike", "Which editor should we use?"); + let choice = entry("choice-spike", "I'd pick Lexical for the spike;"); + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "link-spike", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + questionnaireId: cardId, + }); + let proposed = (version: number): ConversationPlan.Event => ({ + id: "scoped:choice-spike", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: version, + origin: "classifier", + actor: { kind: "classifier" }, + at: choice.ts, + source: { + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + role: "support", + }, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }); + let setup = harness(async input => ({ + events: [proposed(input.state.threads[0]!.version)], + analysis: { questionSetVersion: "test", modelVersion: "fake", status: "applied", passes: [] }, + })); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Lexical" }] }] }, + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(choice); + processor.afterMessage(); + await until(() => setup.durable.state.events.some(item => item.id === proposed(0).id)); + let saved = restoreState(setup.durable.state, [seed, choice]); + expect(saved.threads[0]?.pendingScopedChoice).toMatchObject({ + proposalId: proposed(0).id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + proposer: "ana", + messageId: choice.id, + }); + expect(saved.threads[0]?.pendingSettle).toBeUndefined(); + expect(saved.threads[0]?.decision).toBeUndefined(); + expect(setup.plan.records.get(cardId)?.status).toBe("open"); + expect(setup.plan.conversationPlanPendingEffects).toEqual([{ + key: "scoped-choice:scoped:choice-spike:proposal", + kind: "scoped-choice", + threadId: "thread-a", + proposalId: "scoped:choice-spike", + cardId, + optionId, + label: "Lexical", + scope: "spike", + generation: 0, + triggerEventId: "scoped:choice-spike", + sources: [{ + messageId: choice.id, + author: choice.author as ConversationPlan.SourceAuthor, + quote: choice.text, + start: 0, + end: choice.text.length, + role: "support", + }], + }]); + expect(setup.publications.some(item => item.events.some(event => event.id === proposed(0).id))) + .toBe(true); + let legacy = structuredClone(saved); + delete legacy.threads[0]!.pendingScopedChoice!.proposalId; + expect(restoreState(legacy, [seed, choice]).threads[0]?.pendingScopedChoice?.proposalId) + .toBe(proposed(0).id); + let ambiguous = applyInference(saved, { + ...proposed(saved.threads[0]!.version), + id: "scoped:duplicate-spike", + }, choice); + delete ambiguous.threads[0]!.pendingScopedChoice!.proposalId; + expect(() => restoreState(ambiguous, [seed, choice])).toThrow( + "scoped choice proposal is ambiguous or missing", + ); + processor.stop(); + let mismatched = harness(async input => ({ + events: [proposed(input.state.threads[0]!.version)], + analysis: { questionSetVersion: "test", modelVersion: "fake", status: "applied", passes: [] }, + })); + mismatched.plan.chat.entries.push(seed); + mismatched.plan.conversationPlan = state; + mismatched.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Another editor" }] }] }, + } as never); + let rejected = createProcessor(mismatched.dependencies); + await rejected.accept(choice); + rejected.afterMessage(); + await until(() => mismatched.durable.state.analysis.some(item => item.messageId === choice.id)); + expect(mismatched.durable.state.events.some(item => item.id === proposed(0).id)).toBe(false); + expect(mismatched.durable.state.threads[0]?.pendingScopedChoice).toBeUndefined(); + rejected.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts b/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts new file mode 100644 index 00000000..397c68a2 --- /dev/null +++ b/apps/server/src/conversation-plan/service-legacy-withdrawal.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { entry, harness, opened, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a legacy accepted withdrawal recovers its missing advisory clear and prompt work", async () => { + let setup = harness(); + let question = entry("question", "Which auth system?"); + let proposal = entry("m5", "Let's settle on GitHub Apps"); + let withdrawal = entry("m7", "I no longer support GitHub Apps"); + setup.plan.chat.entries.push(question, proposal, withdrawal); + let state = applyInference(initialState(), opened(question), question); + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + state = applyEvent(state, { + id: "option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 1000, + contribution: { id: optionId, text: "GitHub Apps", authoring: "human-edited" }, + }); + state = applyInference(state, { + id: "proposed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + optionId, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + }, proposal); + state = applyInference(state, { + id: "withdrawn", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: withdrawal.ts, + optionId, + position: "oppose", + source: { + messageId: withdrawal.id, + author: withdrawal.author as ConversationPlan.SourceAuthor, + quote: withdrawal.text, + start: 0, + end: withdrawal.text.length, + role: "objection", + }, + }, withdrawal); + expect(restoreState(state, setup.plan.chat.entries)).toEqual(state); + setup.plan.conversationPlan = state; + let processor = createProcessor(setup.dependencies); + setup.fail(); + processor.wake(); + await until(() => setup.errors.length === 1); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + expect(setup.publications).toEqual([]); + processor.wake(); + await until(() => setup.durable.pending.some(effect => effect.key === "prompt:withdrawn")); + expect(setup.durable.pending).toMatchObject([ + { kind: "insert-card" }, + { key: "suggest:withdrawn", kind: "suggest", messageIds: [] }, + { key: "prompt:withdrawn", kind: "prompt", sourceMessageIds: [] }, + ]); + expect(setup.publications).toEqual([]); + let recovered = structuredClone(setup.durable.pending); + processor.stop(); + let resumed = createProcessor(setup.dependencies); + resumed.wake(); + await Bun.sleep(10); + expect(setup.durable.pending).toEqual(recovered); + resumed.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-memory-failure.test.ts b/apps/server/src/conversation-plan/service-memory-failure.test.ts new file mode 100644 index 00000000..04f84444 --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory-failure.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { entry, until } from "./service.test-fixtures"; +import { memoryProcessor, opening } from "./service-memory.test-fixtures"; + +test("real memory acceptance failure rolls back transcript and queue without publication", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + setup.failNextCommit(); + await expect(processor.accept(entry("memory-rejected", "Which auth system?"))).rejects.toThrow( + "memory commit rejected", + ); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan.queue).toEqual([]); + expect(setup.publications).toEqual([]); + let saved = await setup.saved(); + expect(saved.transcript).toEqual([]); + expect(saved.conversationPlan).toBeUndefined(); + expect(setup.fatals).toHaveLength(1); + await setup.reopen(); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan.queue).toEqual([]); + } finally { + await setup.close(); + } +}); + +test("real memory completion failure preserves pending work and a reopened processor delivers its outbox", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + await processor.accept(entry("memory-completion", "Which auth system?")); + setup.failNextCommit(); + processor.afterMessage(); + await until(() => setup.errors.length === 1); + expect(setup.publications).toHaveLength(1); + expect(setup.publications[0]!.analysis).toEqual([]); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.plan.conversationPlanPendingEffects).toEqual([]); + let failed = await setup.saved(); + expect(failed.conversationPlan.analysis).toEqual([]); + expect(failed.conversationPlan.queue[0]?.status).toBe("pending"); + await setup.reopen(); + processor = setup.start(opening); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + let resumed = await setup.saved(); + expect(resumed.conversationPlan.queue).toEqual([]); + expect(resumed.conversationPlan.analysis[0]?.status).toBe("applied"); + expect(resumed.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.fatals).toHaveLength(1); + } finally { + await setup.close(); + } +}); + +test("real memory receipt failure retains the effect key for callback replay after open", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + await processor.accept(entry("memory-receipt", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + setup.failNextCommit(); + processor.setEffects(setup.sink); + await until(() => setup.errors.length === 1); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(setup.plan.conversationPlanEffects).toEqual([]); + let rejected = await setup.saved(); + expect(rejected.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(rejected.conversationPlanEffects).toBeUndefined(); + expect(setup.fatals).toHaveLength(1); + await setup.reopen(); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + processor = setup.start(); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + expect(setup.calls).toEqual(["insert", "link", "insert", "link"]); + let delivered = await setup.saved(); + expect(delivered.conversationPlanPendingEffects).toBeUndefined(); + expect(delivered.conversationPlanEffects).toEqual(["insert:thread-a"]); + } finally { + await setup.close(); + } +}); diff --git a/apps/server/src/conversation-plan/service-memory.test-fixtures.ts b/apps/server/src/conversation-plan/service-memory.test-fixtures.ts new file mode 100644 index 00000000..143d84dc --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory.test-fixtures.ts @@ -0,0 +1,112 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as PlanService from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createProcessor, type Dependencies, type EffectCommands } from "./service"; +import { opened, unlinked } from "./service.test-fixtures"; + +export async function memoryProcessor() { + let context = await openPlan(); + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.plan.persistence.fatal = context.backend.fatal; + let plan = context.plan; + let processor: ReturnType | undefined; + let errors: unknown[] = []; + let publications: ConversationPlan.State[] = []; + let committed = structuredClone(plan.conversationPlan); + let calls: string[] = []; + let originalCommit = context.storage.collaboration.commit; + let fail = false; + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("memory commit rejected"); + } + return originalCommit(input); + }; + function start(interpret: NonNullable = async () => unlinked()) { + let captured = plan; + processor = createProcessor({ + plan: captured, + exclusive: action => PlanService.exclusive(captured, action), + persist: async () => { + await PlanService.persistExclusive(captured); + committed = structuredClone(captured.conversationPlan); + }, + publish: state => { + expect(state).toEqual(committed); + publications.push(structuredClone(state)); + }, + active: () => true, + interpret, + onError: error => errors.push(error), + }); + return processor; + } + let sink: EffectCommands = { + target: () => ({ kind: "unlinked" }), + insertCard: async () => { + calls.push("insert"); + return "01K0N4W3B7P27CBAEC7A8C8WEA"; + }, + link: async () => { + calls.push("link"); + }, + addOption: async () => { + calls.push("option"); + }, + suggest: async () => { + calls.push("suggest"); + }, + prompt: async () => { + calls.push("prompt"); + }, + report: error => errors.push(error), + }; + return { + get plan() { + return plan; + }, + start, + sink, + calls, + errors, + publications, + fatals, + failNextCommit() { + fail = true; + }, + async saved() { + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + return (loaded!.sidecar ?? loaded!.snapshot!.sidecar) as unknown as { + transcript: unknown[]; + conversationPlan: ConversationPlan.State; + conversationPlanRetries?: Array<{ id: string; messageId: string }>; + conversationPlanPendingEffects?: unknown[]; + conversationPlanEffects?: string[]; + }; + }, + async reopen() { + processor?.stop(); + await PlanService.close(plan); + plan = await PlanService.open(context.channel.id, context.backend, context.server); + committed = structuredClone(plan.conversationPlan); + plan.persistence.fatal = context.backend.fatal; + }, + async close() { + processor?.stop(); + await PlanService.close(plan); + }, + }; +} + +export let opening: NonNullable = async ({ message }) => ({ + events: [opened(message)], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied", + passes: [], + }, +}); diff --git a/apps/server/src/conversation-plan/service-memory.test.ts b/apps/server/src/conversation-plan/service-memory.test.ts new file mode 100644 index 00000000..09a7b0c4 --- /dev/null +++ b/apps/server/src/conversation-plan/service-memory.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { entry, until } from "./service.test-fixtures"; +import { memoryProcessor, opening } from "./service-memory.test-fixtures"; + +test("real memory storage commits transcript and queue, then resumes analysis and receipts across opens", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(opening); + let message = entry("memory-open", "Which auth system?"); + await processor.accept(message); + let accepted = await setup.saved(); + expect(accepted.transcript).toEqual([message]); + expect(accepted.conversationPlan.queue).toEqual([{ + messageId: message.id, + status: "pending", + attempts: 0, + }]); + expect(setup.publications).toEqual([]); + await setup.reopen(); + expect(setup.plan.chat.entries).toEqual([message]); + processor = setup.start(opening); + processor.wake(); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + let analyzed = await setup.saved(); + expect(analyzed.conversationPlan.queue).toEqual([]); + expect(analyzed.conversationPlan.events.map(event => event.id)).toEqual(["open:memory-open"]); + expect(analyzed.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + expect(analyzed.conversationPlanEffects).toBeUndefined(); + await setup.reopen(); + expect(setup.plan.conversationPlanPendingEffects).toMatchObject([{ key: "insert:thread-a" }]); + processor = setup.start(); + processor.setEffects(setup.sink); + await until(() => setup.plan.conversationPlanEffects.includes("insert:thread-a")); + let delivered = await setup.saved(); + expect(delivered.conversationPlanPendingEffects).toBeUndefined(); + expect(delivered.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + await setup.reopen(); + processor = setup.start(); + processor.setEffects(setup.sink); + await Bun.sleep(10); + expect(setup.plan.conversationPlanEffects).toEqual(["insert:thread-a"]); + expect(setup.calls).toEqual(["insert", "link"]); + expect(setup.errors).toEqual([]); + } finally { + await setup.close(); + } +}); + +test("real memory storage keeps failed inference and durable retry identity across open", async () => { + let setup = await memoryProcessor(); + try { + let processor = setup.start(async () => { + throw new Error("injected interpreter failure"); + }); + await processor.accept(entry("memory-retry", "Which auth system?")); + processor.afterMessage(); + await until(() => setup.publications.some(state => state.queue[0]?.status === "failed")); + let failed = await setup.saved(); + expect(failed.conversationPlan.analysis[0]).toMatchObject({ + status: "failed", + error: "Jev interpretation failed", + }); + await setup.reopen(); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("failed"); + processor = setup.start(opening); + await processor.retry("memory-retry-action", "memory-retry", { kind: "member", handle: "ana" }); + await until(() => setup.publications.some(state => state.analysis[0]?.status === "applied")); + let retried = await setup.saved(); + expect(retried.conversationPlanRetries).toEqual([{ + id: "human:ana:memory-retry-action", + messageId: "memory-retry", + }]); + expect(retried.conversationPlan.queue).toEqual([]); + await setup.reopen(); + processor = setup.start(); + expect( + await processor.retry("memory-retry-action", "memory-retry", { + kind: "member", + handle: "ana", + }), + ).toEqual({ messageId: "memory-retry", queued: false }); + expect(setup.plan.conversationPlanRetries).toEqual(retried.conversationPlanRetries!); + } finally { + await setup.close(); + } +}); diff --git a/apps/server/src/conversation-plan/service-mirror-race.test.ts b/apps/server/src/conversation-plan/service-mirror-race.test.ts new file mode 100644 index 00000000..37284d3b --- /dev/null +++ b/apps/server/src/conversation-plan/service-mirror-race.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; +import { mirroredEvent } from "./card-mirror"; +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("in-flight agreement waits for Save and Reopen mirrors, then reinterprets", async () => { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("seed", "Which auth system?"); + let assent = { + ...entry("assent", "GitHub Apps sounds good."), + author: { kind: "member" as const, handle: "ben" }, + }; + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: `card:${cardId}:option:${optionId}`, + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: 1000, + contribution: { id: optionId, text: "GitHub Apps", authoring: "human-edited" }, + }); + state = applyEvent(state, { + id: `card:${cardId}:linked`, + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + state = applyEvent(state, { + id: "suggest:seed", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + optionId, + source: { + messageId: seed.id, + author: seed.author as ConversationPlan.SourceAuthor, + quote: seed.text, + start: 0, + end: seed.text.length, + role: "resolution", + }, + }); + let held = deferred>(); + let calls = 0; + let setup = harness(async ({ state: input }) => { + calls++; + if (calls > 1) return unlinked(); + return held.promise.then(() => ({ + events: [{ + id: "agree:assent", + type: "settle.agreed" as const, + threadId: "thread-a", + observedThreadVersion: input.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + optionId, + source: { + messageId: assent.id, + author: assent.author, + quote: assent.text, + start: 0, + end: assent.text.length, + role: "support" as const, + }, + }], + analysis: { + questionSetVersion: "conversation-plan-4", + modelVersion: "fake", + status: "applied" as const, + passes: [], + }, + })); + }); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + status: "open", + history: [], + } as never); + let processor = createProcessor(setup.dependencies); + await processor.accept(assent); + processor.afterMessage(); + await until(() => calls === 1); + await setup.dependencies.exclusive(async () => { + setup.plan.records.set(cardId, { + status: "reopened", + history: [{ choices: [optionId], owner: "ana", at: 1000 }], + } as never); + setup.plan.pendingCardActions = [{ + id: `card:${cardId}:decided:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 1000, + kind: "decided", + generation: 1, + optionIds: [optionId], + text: "GitHub Apps", + }, { + id: `card:${cardId}:reopened:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 1000, + kind: "reopened", + generation: 1, + }]; + await setup.dependencies.persist(); + }); + let checked = deferred(); + let exclusive = setup.dependencies.exclusive; + setup.dependencies.exclusive = async action => { + let result = await exclusive(action); + checked.resolve(); + return result; + }; + held.resolve(unlinked()); + await checked.promise; + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(calls).toBe(1); + expect(setup.errors).toEqual([]); + expect(setup.plan.conversationPlan.events.some(event => event.type === "settle.agreed")) + .toBe(false); + expect( + setup.durable.pending.filter(effect => effect.kind === "prompt").map(effect => effect.key), + ) + .toEqual(["prompt:suggest:seed"]); + let actions = setup.plan.pendingCardActions.slice(); + for (let action of actions) { + await processor.record(current => { + let thread = current.threads.find(item => item.id === action.threadId)!; + return mirroredEvent(thread, action); + }, action.id); + } + await until(() => setup.plan.conversationPlan.queue.length === 0); + expect(calls).toBe(2); + expect(setup.plan.conversationPlan.threads[0]?.status).toBe("reopened"); + expect(setup.errors).toEqual([]); + expect( + setup.durable.pending.filter(effect => effect.kind === "prompt").map(effect => effect.key), + ) + .toEqual(["prompt:suggest:seed"]); +}); diff --git a/apps/server/src/conversation-plan/service-order.test.ts b/apps/server/src/conversation-plan/service-order.test.ts new file mode 100644 index 00000000..1be4d687 --- /dev/null +++ b/apps/server/src/conversation-plan/service-order.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { createProcessor } from "./service"; +import { entry, harness } from "./service.test-fixtures"; + +test("processor captures plan and initial effects once before reporting dependency", async () => { + let setup = harness(); + let order: string[] = []; + let planReads = 0; + let effectReads = 0; + let alternate = structuredClone(setup.plan); + Object.defineProperty(setup.dependencies, "plan", { + get() { + order.push("plan"); + return ++planReads === 1 ? setup.plan : alternate; + }, + }); + Object.defineProperty(setup.dependencies, "effects", { + get() { + order.push("effects"); + effectReads++; + if (effectReads > 1) throw new Error("effects reread"); + return undefined; + }, + }); + let onError = setup.dependencies.onError; + Object.defineProperty(setup.dependencies, "onError", { + get() { + order.push("onError"); + return onError; + }, + }); + let processor = createProcessor(setup.dependencies); + try { + expect(order).toEqual(["plan", "effects", "onError"]); + expect(Object.keys(processor)).toEqual([ + "accept", + "afterMessage", + "correct", + "saveScopedChoice", + "researchConsent", + "record", + "retry", + "setEffects", + "wake", + "stop", + "idle", + ]); + await processor.accept(entry("captured-plan", "Which auth system?")); + expect(planReads).toBe(1); + expect(effectReads).toBe(1); + expect(setup.plan.chat.entries.map(message => message.id)).toEqual(["captured-plan"]); + expect(alternate.chat.entries).toEqual([]); + } finally { + processor.stop(); + } +}); diff --git a/apps/server/src/conversation-plan/service-queue.test.ts b/apps/server/src/conversation-plan/service-queue.test.ts new file mode 100644 index 00000000..430435d6 --- /dev/null +++ b/apps/server/src/conversation-plan/service-queue.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; + +import { applyInference, initialState, restoreState } from "./domain"; + +import { createProcessor } from "./service"; + +import { deferred, entry, harness, opened, unlinked, until } from "./service.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("acceptance persists transcript and queue together, then runs FIFO with prior context", async () => { + let seen: Array<{ id: string; recent: string[] }> = []; + let first = deferred>(); + let setup = harness(async input => { + seen.push({ id: input.message.id, recent: input.recent.map(item => item.id) }); + return input.message.id === "one" ? first.promise : unlinked(); + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "First proposal.")); + await processor.accept(entry("two", "Second proposal.")); + expect(setup.durable.entries.map(item => item.id)).toEqual(["one", "two"]); + expect(setup.durable.state.queue.map(item => item.messageId)).toEqual(["one", "two"]); + expect(setup.publications).toHaveLength(0); + processor.afterMessage(); + await until(() => seen.length === 1); + first.resolve(unlinked()); + await until(() => setup.durable.state.queue.length === 0); + expect(seen).toEqual([ + { id: "one", recent: [] }, + { id: "two", recent: ["one"] }, + ]); + expect(setup.publications.at(-1)).toEqual(setup.durable.state); +}); + +test("failed acceptance rolls back both values and never publishes", async () => { + let setup = harness(); + let processor = createProcessor(setup.dependencies); + setup.fail(); + await expect(processor.accept(entry("one", "Should we use an outline?"))) + .rejects.toThrow("storage failed"); + expect(setup.plan.chat.entries).toEqual([]); + expect(setup.plan.conversationPlan).toEqual(initialState()); + expect(setup.durable.entries).toEqual([]); + expect(setup.publications).toEqual([]); +}); + +test("failed completion keeps pending work for the next open and publishes nothing new", async () => { + let response = deferred>(); + let started = false; + let setup = harness(async () => { + started = true; + return response.promise; + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "An option.")); + processor.afterMessage(); + await until(() => started); + let published = setup.publications.length; + setup.fail(); + response.resolve(unlinked()); + await until(() => setup.errors.length === 1); + expect(setup.plan.conversationPlan.queue[0]?.status).toBe("pending"); + expect(setup.durable.state.queue[0]?.status).toBe("pending"); + expect(setup.publications).toHaveLength(published); +}); + +test("stale inference reruns after a correction without losing a later enqueue", async () => { + let pending = deferred>(); + let calls = 0; + let setup = harness(async () => { + calls++; + return calls === 1 ? pending.promise : unlinked(); + }); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Maybe make it optional.")); + processor.afterMessage(); + await until(() => calls === 1); + await processor.accept(entry("two", "I agree with optional.")); + await processor.correct({ + actionId: "edit-1", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Which outline should we use?" }, + }, { kind: "member", handle: "ana" }); + pending.resolve(unlinked()); + await until(() => setup.plan.conversationPlan.queue.length === 0); + expect(calls).toBe(3); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe( + "Which outline should we use?", + ); + expect(setup.plan.conversationPlan.analysis.map(item => item.messageId)).toEqual([ + "one", + "two", + ]); +}); + +test("stop leaves in-flight work durable and a restored processor resumes it", async () => { + let pending = deferred>(); + let started = false; + let setup = harness(async () => { + started = true; + return pending.promise; + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "Use an outline.")); + processor.afterMessage(); + await until(() => started); + processor.stop(); + pending.resolve(unlinked()); + await Bun.sleep(2); + expect(setup.durable.state.queue[0]?.status).toBe("pending"); + let restored = harness(); + restored.plan.chat.entries = structuredClone(setup.durable.entries); + restored.plan.conversationPlan = restoreState( + setup.durable.state, + restored.plan.chat.entries, + ); + let resumed = createProcessor(restored.dependencies); + resumed.wake(); + await until(() => restored.plan.conversationPlan.queue.length === 0); + expect(restored.plan.conversationPlan.analysis[0]?.messageId).toBe("one"); +}); + +test("failed analysis is durable; retry keys are idempotent and collision checked", async () => { + let calls = 0; + let setup = harness(async () => { + calls++; + return calls === 1 + ? { + events: [], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "failed", + passes: [], + error: "temporary failure", + }, + } + : unlinked(); + }); + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("one", "An option.")); + processor.afterMessage(); + await until(() => setup.durable.state.queue[0]?.status === "failed"); + expect(setup.durable.state.analysis[0]?.error).toBe("temporary failure"); + await processor.retry("retry-1", "one", { kind: "member", handle: "ana" }); + await until(() => setup.durable.state.queue.length === 0); + expect(calls).toBe(2); + expect(await processor.retry("retry-1", "one", { kind: "member", handle: "ana" })) + .toEqual({ messageId: "one", queued: false }); + await expect(processor.retry("retry-1", "other", { kind: "member", handle: "ana" })) + .rejects.toThrow("collision"); + expect(setup.durable.retries).toEqual([{ id: "human:ana:retry-1", messageId: "one" }]); +}); + +test("a full analysis queue skips new work without rejecting chat", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + let prior = applyInference(initialState(), opened(seed), seed); + setup.plan.conversationPlan = { + ...prior, + queue: Array.from({ length: 128 }, (_, index) => ({ + messageId: `old-${index}`, + status: "failed" as const, + attempts: 0, + })), + analysis: Array.from({ length: 64 }, (_, index) => ({ + messageId: `analysis-${index}`, + questionSetVersion: "test", + modelVersion: "fake", + status: "unlinked" as const, + passes: [], + eventIds: [], + })), + }; + let processor = createProcessor(setup.dependencies); + await processor.accept(entry("new", "A new question.")); + expect(setup.durable.entries.at(-1)?.id).toBe("new"); + expect(setup.durable.state.events).toEqual(prior.events); + expect(setup.durable.state.threads).toEqual(prior.threads); + expect(setup.durable.state.analysis.at(-1)).toMatchObject({ + messageId: "new", + status: "unlinked", + policyGate: "queue full; skipped", + }); + expect(setup.durable.state.analysis).toHaveLength(64); + expect(setup.durable.state.analysis[0]?.messageId).toBe("analysis-1"); + expect(setup.durable.state.revision).toBe(prior.revision + 1); + expect(setup.durable.state.queue).toHaveLength(128); + expect(setup.publications).toHaveLength(0); + let accepted = setup.plan.conversationPlan; + setup.fail(); + await expect(processor.accept(entry("newer", "Another question."))) + .rejects.toThrow("storage failed"); + expect(setup.plan.conversationPlan).toBe(accepted); + expect(setup.plan.chat.entries.map(item => item.id)).toEqual(["seed", "new"]); + expect(setup.durable.entries.map(item => item.id)).toEqual(["seed", "new"]); +}); + +test("inactive and forged human actions cannot mutate the sidecar", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + await expect(processor.correct({ + actionId: "bad", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Forged" }, + }, { kind: "classifier" } as never)).rejects.toThrow("member"); + setup.active = false; + await expect(processor.correct({ + actionId: "archived", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Archived" }, + }, { kind: "member", handle: "ana" })).rejects.toThrow("unavailable"); + await expect(processor.retry("retry", "seed", { kind: "member", handle: "ana" })) + .rejects.toThrow("unavailable"); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe(seed.text); +}); + +test("failed correction commit restores the previous card and emits no change", async () => { + let setup = harness(); + let seed = entry("seed", "Should we use an outline?"); + setup.plan.chat.entries.push(seed); + setup.plan.conversationPlan = applyInference(initialState(), opened(seed), seed); + let processor = createProcessor(setup.dependencies); + setup.fail(); + await expect(processor.correct({ + actionId: "edit-failed", + threadId: "thread-a", + expectedVersion: 1, + change: { kind: "edit", field: "question", text: "Different question" }, + }, { kind: "member", handle: "ana" })).rejects.toThrow("storage failed"); + expect(setup.plan.conversationPlan.threads[0]?.question).toBe(seed.text); + expect(setup.publications).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-save.test.ts b/apps/server/src/conversation-plan/service-scoped-save.test.ts new file mode 100644 index 00000000..9414f12c --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-save.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; + +import { restoreState } from "./domain"; + +import { createProcessor } from "./service"; + +import { + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("a member can Save an m6-only scoped choice without answering the card", async () => { + let fixture = scopedSaveFixture(); + let { setup } = fixture; + let processor = createProcessor(setup.dependencies); + let input = scopedChoiceSaveInput(fixture); + let result = await saveScopedChoice(processor, input); + let saved = setup.durable.state.events.at(-1); + let savedEvent = saved as unknown as { + type: string; + proposalId: string; + agreementId?: string; + } | undefined; + + expect(result.eventId).toBe("human:Rob:save-spike-choice"); + expect(savedEvent).toMatchObject({ + type: "scoped-choice.saved", + origin: "human", + actor: { kind: "member", handle: "Rob" }, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + label: "Lexical", + scope: "spike", + }); + expect(savedEvent?.agreementId).toBeUndefined(); + let proposalEvent = setup.durable.state.events.find(event => event.id === savedEvent?.proposalId); + expect(proposalEvent && "source" in proposalEvent ? proposalEvent.source : undefined) + .toEqual(fixture.proposal.source); + let savedThread = setup.plan.conversationPlan.threads[0]!; + expect(savedThread).toMatchObject({ + status: "exploring", + pendingScopedChoice: { proposalId: fixture.proposal.id, optionId: fixture.optionId }, + decisionHistory: [], + }); + expect(savedThread.pendingSettle).toBeUndefined(); + expect(savedThread.decision).toBeUndefined(); + expect(setup.plan.records.get(fixture.cardId)).toMatchObject({ status: "open", history: [] }); + expect(setup.publications.at(-1)?.events.at(-1)).toMatchObject({ + type: "scoped-choice.saved", + id: result.eventId, + }); + expect(restoreState(setup.durable.state, setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + let altered = structuredClone(setup.durable.state); + let alteredSave = altered.events.at(-1); + if (alteredSave?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + alteredSave.sources[0].quote = "I would pick another editor for the spike."; + expect(() => restoreState(altered, setup.plan.chat.entries)).toThrow(); + processor.stop(); +}); + +test("Save optionally links Rob's m7 agreement as a second exact source", async () => { + let fixture = scopedSaveFixture(true); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(event => event.id === result.eventId); + let savedEvent = saved as unknown as { + type: string; + proposalId: string; + agreementId?: string; + } | undefined; + + expect(savedEvent).toMatchObject({ + type: "scoped-choice.saved", + proposalId: fixture.proposal.id, + agreementId: fixture.agreement.id, + }); + let proposalEvent = fixture.setup.durable.state.events.find(event => + event.id === savedEvent?.proposalId + ); + let agreementEvent = fixture.setup.durable.state.events.find(event => + event.id === savedEvent?.agreementId + ); + expect(proposalEvent && "source" in proposalEvent ? proposalEvent.source : undefined) + .toEqual(fixture.proposal.source); + expect(agreementEvent && "source" in agreementEvent ? agreementEvent.source : undefined) + .toEqual(fixture.agreement.source); + expect(fixture.setup.plan.records.get(fixture.cardId)).toMatchObject({ + status: "open", + history: [], + }); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-stale.test.ts b/apps/server/src/conversation-plan/service-scoped-stale.test.ts new file mode 100644 index 00000000..46ea9716 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-stale.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; + +import { applyInference } from "./domain"; + +import { createProcessor } from "./service"; + +import { entry } from "./service.test-fixtures"; +import { + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("optionless proposer non-support rejects a stale scoped Save command", async () => { + let fixture = scopedSaveFixture(); + let { setup } = fixture; + let opposition = entry("save-mei-optionless", "I don't support that choice anymore."); + opposition.author = { kind: "member", handle: "Mei" }; + opposition.ts = 1003; + setup.plan.conversationPlan = applyInference(setup.plan.conversationPlan, { + id: "save-mei-optionless-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: opposition.ts, + source: { + messageId: opposition.id, + author: opposition.author, + quote: opposition.text, + start: 0, + end: opposition.text.length, + role: "objection", + }, + position: "oppose", + }, opposition); + setup.plan.chat.entries.push(opposition); + await setup.dependencies.persist(); + let processor = createProcessor(setup.dependencies); + try { + await expect(saveScopedChoice(processor, scopedChoiceSaveInput(fixture))) + .rejects.toThrow(); + expect(setup.durable.state.events.some(event => event.type === "scoped-choice.saved")) + .toBe(false); + } finally { + processor.stop(); + } +}); + +test("scoped Save is idempotent and rejects stale card snapshots", async () => { + let fixture = scopedSaveFixture(); + let processor = createProcessor(fixture.setup.dependencies); + let input = scopedChoiceSaveInput(fixture); + let first = await saveScopedChoice(processor, input); + let publicationCount = fixture.setup.publications.length; + let duplicate = await saveScopedChoice(processor, input); + expect(duplicate).toEqual(first); + await expect(saveScopedChoice(processor, { ...input, expectedGeneration: 1 })) + .rejects.toThrow("scoped choice action ID already used"); + expect( + fixture.setup.durable.state.events.filter(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toHaveLength(1); + expect(fixture.setup.publications).toHaveLength(publicationCount); + processor.stop(); + + for (let stale of ["generation", "label", "closed card"] as const) { + let next = scopedSaveFixture(); + let record = next.setup.plan.records.get(next.cardId) as { + status: string; + history: unknown[]; + definition: { questions: Array<{ options: Array<{ id: string; label: string }> }> }; + }; + let staleInput = scopedChoiceSaveInput(next, `stale-${stale}`); + if (stale === "generation") staleInput.expectedGeneration = 1; + if (stale === "label") record.definition.questions[0]!.options[0]!.label = "Tiptap"; + if (stale === "closed card") record.status = "decided"; + let staleProcessor = createProcessor(next.setup.dependencies); + await expect(saveScopedChoice(staleProcessor, staleInput)).rejects.toThrow(); + expect( + next.setup.plan.conversationPlan.events.some(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toBe(false); + expect(next.setup.publications).toEqual([]); + staleProcessor.stop(); + } +}); + +test("a failed scoped Save commit publishes no saved choice", async () => { + let fixture = scopedSaveFixture(); + let processor = createProcessor(fixture.setup.dependencies); + fixture.setup.fail(); + await expect(saveScopedChoice(processor, scopedChoiceSaveInput(fixture))) + .rejects.toThrow("storage failed"); + expect( + fixture.setup.plan.conversationPlan.events.some(event => + (event as { type: string }).type === "scoped-choice.saved" + ), + ).toBe(false); + expect(fixture.setup.publications).toEqual([]); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped-support.test.ts b/apps/server/src/conversation-plan/service-scoped-support.test.ts new file mode 100644 index 00000000..2d139291 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped-support.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from "bun:test"; + +import { applyInference, restoreState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; + +import { entry } from "./service.test-fixtures"; +import { + addScopedSupport, + retractScopedSupport, + saveScopedChoice, + scopedChoiceSaveInput, + scopedSaveFixture, +} from "./service-scoped.test-fixtures"; + +// Whole callbacks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +test("scoped Save retains exact sources from every current supporter", async () => { + let fixture = scopedSaveFixture(true); + let jo = addScopedSupport(fixture, "Jo", "save-m8-jo", "save-agreement-jo"); + await fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([ + fixture.proposal.source, + fixture.agreement.source, + jo.agreement.source, + ]); + expect(fixture.setup.durable.state.events.filter(item => item.type === "scoped-choice.saved")) + .toHaveLength(1); + let thread = fixture.setup.plan.conversationPlan.threads[0]; + expect(thread).toMatchObject({ + status: "exploring", + decisionHistory: [], + }); + expect(thread?.pendingSettle).toBeUndefined(); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("a proposer withdrawal does not remove a remaining supporter's Save path", async () => { + let fixture = scopedSaveFixture(true); + retractScopedSupport(fixture, "Mei", "save-mei-withdraws"); + fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([fixture.agreement.source]); + expect(saved.sources).not.toContain(fixture.proposal.source); + let thread = fixture.setup.plan.conversationPlan.threads[0]; + expect(thread).toMatchObject({ + status: "exploring", + decisionHistory: [], + }); + expect(thread?.pendingSettle).toBeUndefined(); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("retracting one supporter keeps the other current supporter in the saved sources", async () => { + let fixture = scopedSaveFixture(true); + let jo = addScopedSupport(fixture, "Jo", "save-m8-jo", "save-agreement-jo"); + retractScopedSupport(fixture, "Rob", "save-rob-withdraws"); + fixture.setup.dependencies.persist(); + let processor = createProcessor(fixture.setup.dependencies); + let result = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let saved = fixture.setup.durable.state.events.find(item => item.id === result.eventId); + expect(saved?.type).toBe("scoped-choice.saved"); + if (saved?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(saved.sources).toEqual([fixture.proposal.source, jo.agreement.source]); + expect(saved.sources).not.toContain(fixture.agreement.source); + expect(restoreState(fixture.setup.durable.state, fixture.setup.plan.chat.entries).events.at(-1)) + .toEqual(saved); + processor.stop(); +}); + +test("scoped Save excludes a support source withdrawn after its agreement", async () => { + let fixture = scopedSaveFixture(true); + let { setup } = fixture; + let state = applyEvent(setup.plan.conversationPlan, { + id: "save-option-for-retraction", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1003, + contribution: { id: fixture.optionId, text: "Lexical", authoring: "human-edited" }, + }); + let retraction = entry("save-rob-retracts", "I oppose Lexical now."); + retraction.author = { kind: "member", handle: "Rob" }; + retraction.ts = 1004; + state = applyInference(state, { + id: "save-rob-opposes", + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: retraction.ts, + source: { + messageId: retraction.id, + author: retraction.author, + quote: retraction.text, + start: 0, + end: retraction.text.length, + role: "objection", + }, + optionId: fixture.optionId, + position: "oppose", + }, retraction); + setup.plan.chat.entries.push(retraction); + setup.plan.conversationPlan = state; + await setup.dependencies.persist(); + let processor = createProcessor(setup.dependencies); + let saved = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let event = setup.durable.state.events.find(item => item.id === saved.eventId); + expect(event?.type).toBe("scoped-choice.saved"); + if (event?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(event.agreementId).toBeUndefined(); + expect(event.sources).toEqual([fixture.proposal.source]); + processor.stop(); +}); + +test("scoped Save keeps an agreement after an unlinked named-option objection", async () => { + let fixture = scopedSaveFixture(true); + let { setup } = fixture; + let state = applyEvent(setup.plan.conversationPlan, { + id: "save-option-for-unlinked-objection", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: setup.plan.conversationPlan.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1003, + contribution: { id: fixture.optionId, text: "Lexical", authoring: "human-edited" }, + }); + let objection = entry("save-rob-unlinked-objection", "I oppose Lexical."); + objection.author = { kind: "member", handle: "Rob" }; + objection.ts = 1004; + state = applyInference(state, { + id: "save-rob-unlinked-opposes", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: objection.ts, + source: { + messageId: objection.id, + author: objection.author, + quote: objection.text, + start: 0, + end: objection.text.length, + role: "objection", + }, + optionId: fixture.optionId, + position: "oppose", + }, objection); + setup.plan.chat.entries.push(objection); + setup.plan.conversationPlan = state; + let processor = createProcessor(setup.dependencies); + let saved = await saveScopedChoice(processor, scopedChoiceSaveInput(fixture)); + let event = setup.durable.state.events.find(item => item.id === saved.eventId); + expect(event?.type).toBe("scoped-choice.saved"); + if (event?.type !== "scoped-choice.saved") throw new Error("missing saved choice"); + expect(event.agreementId).toBe(fixture.agreement.id); + expect(event.sources).toEqual([fixture.proposal.source, fixture.agreement.source]); + processor.stop(); +}); diff --git a/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts new file mode 100644 index 00000000..6b67b314 --- /dev/null +++ b/apps/server/src/conversation-plan/service-scoped.test-fixtures.ts @@ -0,0 +1,212 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { applyEvent } from "./events"; + +import { createProcessor } from "./service"; +import { entry, harness, opened } from "./service.test-fixtures"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export function scopedSaveFixture(withAgreement = false) { + let cardId = "01K0N4W3B7P27CBAEC7A8C8WEA"; + let optionId = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let seed = entry("save-seed", "Which editor should we use?"); + let m6: Chat.Entry = { + ...entry( + "save-m6", + "agreed. I'd pick Lexical for the spike; still want to see how it handles pasted tables.", + ), + author: { kind: "member", handle: "Mei" }, + ts: 1001, + }; + let m7: Chat.Entry = { + ...entry("save-m7", "yep, Lexical for the spike. not a final library call yet."), + author: { kind: "member", handle: "Rob" }, + ts: 1002, + }; + let state = applyInference(initialState(), opened(seed), seed); + state = applyEvent(state, { + id: "save-link-spike", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + questionnaireId: cardId, + }); + let proposal: ConversationPlan.Event = { + id: "save-proposal-m6", + type: "scoped-choice.proposed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: m6.ts, + source: { + messageId: m6.id, + author: m6.author as ConversationPlan.SourceAuthor, + quote: "I'd pick Lexical for the spike;", + start: 8, + end: 39, + role: "support", + }, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + state = applyInference(state, proposal, m6); + let agreement: ConversationPlan.Event = { + id: "save-agreement-m7", + type: "scoped-choice.agreed", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: m7.ts, + source: { + messageId: m7.id, + author: m7.author as ConversationPlan.SourceAuthor, + quote: "yep, Lexical for the spike.", + start: 0, + end: 27, + role: "support", + }, + proposalId: proposal.id, + cardId, + optionId, + label: "Lexical", + scope: "spike", + }; + if (withAgreement) state = applyInference(state, agreement, m7); + let setup = harness(); + setup.plan.chat.entries.push(seed, m6, ...(withAgreement ? [m7] : [])); + setup.plan.conversationPlan = state; + setup.plan.records.set(cardId, { + id: cardId, + threadId: "thread-a", + status: "open", + history: [], + definition: { questions: [{ options: [{ id: optionId, label: "Lexical" }] }] }, + } as never); + return { setup, cardId, optionId, proposal, agreement, m6, m7 }; +} + +export function addScopedSupport( + fixture: ReturnType, + handle: string, + messageId: string, + agreementId: string, +) { + let message = entry(messageId, "yep, Lexical for the spike."); + message.author = { kind: "member", handle }; + message.ts = 1003; + let thread = fixture.setup.plan.conversationPlan.threads[0]!; + let agreement: ConversationPlan.Event = { + id: agreementId, + type: "scoped-choice.agreed", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "support", + }, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + label: "Lexical", + scope: "spike", + }; + fixture.setup.plan.conversationPlan = applyInference( + fixture.setup.plan.conversationPlan, + agreement, + message, + ); + fixture.setup.plan.chat.entries.push(message); + return { agreement, message }; +} + +export function retractScopedSupport( + fixture: ReturnType, + handle: string, + messageId: string, +) { + let message = entry(messageId, "I withdraw my support for Lexical for the spike."); + message.author = { kind: "member", handle }; + message.ts = 1004; + let thread = fixture.setup.plan.conversationPlan.threads[0]!; + let retraction: ConversationPlan.Event = { + id: `${messageId}:stance`, + type: "stance.changed", + scopedProposalId: fixture.proposal.id, + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "withdrawal", + }, + position: "neutral", + }; + fixture.setup.plan.conversationPlan = applyInference( + fixture.setup.plan.conversationPlan, + retraction, + message, + ); + fixture.setup.plan.chat.entries.push(message); + return { retraction, message }; +} + +export type ScopedChoiceSaveInput = { + actionId: string; + threadId: string; + expectedVersion: number; + proposalId: string; + cardId: string; + optionId: string; + expectedLabel: string; + expectedGeneration: number; +}; + +export async function saveScopedChoice( + processor: ReturnType, + input: ScopedChoiceSaveInput, + actor = { kind: "member", handle: "Rob" } as Extract, +) { + let saver = processor as unknown as { + saveScopedChoice( + input: ScopedChoiceSaveInput, + actor: Extract, + ): Promise<{ eventId: string; revision: number }>; + }; + return saver.saveScopedChoice(input, actor); +} + +export function scopedChoiceSaveInput( + fixture: ReturnType, + actionId = "save-spike-choice", +): ScopedChoiceSaveInput { + return { + actionId, + threadId: "thread-a", + expectedVersion: fixture.setup.plan.conversationPlan.threads[0]!.version, + proposalId: fixture.proposal.id, + cardId: fixture.cardId, + optionId: fixture.optionId, + expectedLabel: "Lexical", + expectedGeneration: 0, + }; +} diff --git a/apps/server/src/conversation-plan/service.test-fixtures.ts b/apps/server/src/conversation-plan/service.test-fixtures.ts new file mode 100644 index 00000000..38e45875 --- /dev/null +++ b/apps/server/src/conversation-plan/service.test-fixtures.ts @@ -0,0 +1,146 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; + +import type { Effect } from "./effects"; +import type { PendingCardAction } from "../questions/card-actions"; +import type { Dependencies } from "./service"; + +// Exact helpers from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.test.ts. +export type PlanData = Dependencies["plan"]; + +export function entry(id: string, text: string): Chat.Entry { + return { id, text, ts: 1000, author: { kind: "member", handle: "ana" } }; +} + +export function opened(message: Chat.Entry): ConversationPlan.Event { + return { + id: `open:${message.id}`, + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + type: "thread.opened", + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; +} + +export function unlinked(): Awaited>> { + return { + events: [], + analysis: { + questionSetVersion: "test", + modelVersion: "fake", + status: "unlinked", + passes: [], + }, + }; +} + +export function deferred() { + let resolve!: (value: T) => void; + let promise = new Promise(done => resolve = done); + return { promise, resolve }; +} + +export async function until(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + if (condition()) return; + await Bun.sleep(1); + } + throw new Error("condition did not become true"); +} + +export function harness( + interpret: NonNullable = async () => unlinked(), +) { + let plan = { + id: "channel", + chat: { entries: [] as Chat.Entry[] }, + conversationPlan: initialState(), + conversationPlanRetries: [] as Array<{ id: string; messageId: string }>, + conversationPlanEffects: [] as string[], + conversationPlanPendingEffects: [] as Effect[], + pendingCardActions: [] as PendingCardAction[], + records: new Map(), + } as PlanData; + let durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + retries: plan.conversationPlanRetries, + receipts: plan.conversationPlanEffects, + pending: plan.conversationPlanPendingEffects, + pendingCards: plan.pendingCardActions, + records: plan.records, + }); + let publications: ConversationPlan.State[] = []; + let active = true; + let failNext = false; + let locked = false; + let tail = Promise.resolve(); + let errors: unknown[] = []; + let dependencies: Dependencies = { + plan, + exclusive: action => { + let operation = tail.then(async () => { + locked = true; + try { + return await action(); + } finally { + locked = false; + } + }); + tail = operation.then(() => {}, () => {}); + return operation; + }, + persist: async () => { + if (failNext) { + failNext = false; + throw new Error("storage failed"); + } + durable = structuredClone({ + entries: plan.chat.entries, + state: plan.conversationPlan, + retries: plan.conversationPlanRetries, + receipts: plan.conversationPlanEffects, + pending: plan.conversationPlanPendingEffects, + pendingCards: plan.pendingCardActions, + records: plan.records, + }); + }, + publish: state => { + expect(durable.state).toEqual(state); + publications.push(structuredClone(state)); + }, + active: () => active, + interpret: async (input, signal) => { + expect(locked).toBe(false); + return interpret(input, signal); + }, + onError: error => errors.push(error), + }; + return { + plan, + dependencies, + get durable() { + return durable; + }, + publications, + errors, + set active(value: boolean) { + active = value; + }, + fail() { + failNext = true; + }, + }; +} diff --git a/apps/server/src/conversation-plan/service.ts b/apps/server/src/conversation-plan/service.ts new file mode 100644 index 00000000..a7d08807 --- /dev/null +++ b/apps/server/src/conversation-plan/service.ts @@ -0,0 +1,115 @@ +import type { Dependencies } from "./processor-types"; +export { MAX_PENDING_EFFECTS } from "./processor-fields"; +export { researchQuoteFocus, researchQuoteSupportsPair } from "./processor-research-quotes"; +export type { Dependencies, EffectCommands } from "./processor-types"; +import { createOutbox } from "./processor-outbox"; +import { createCommands } from "./processor-commands"; +import { createScopedSave } from "./processor-scoped-save"; +import { createResearchConsent } from "./processor-research-consent"; +import { createRun } from "./processor-run"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, service.ts; import/export and synchronous closure wrappers only. + +export type Processor = ReturnType; + +export function createProcessor(deps: Dependencies) { + let { plan } = deps; + let stopped = false; + let controller = new AbortController(); + let running: Promise | undefined; + let commands = new Set>(); + let wakeRequested = false; + let effectCommands = deps.effects; + let report = deps.onError ?? ((error: unknown) => console.error("[conversation-plan]", error)); + let active = () => !stopped && deps.active(); + let publish = () => { + try { + deps.publish(plan.conversationPlan); + } catch (error) { + report(error); + } + }; + let { markApplied, recover, drainEffects, setEffects } = createOutbox( + deps, + plan, + active, + wake, + effectCommands, + ); + let { accept, afterMessage, correct, record, retry } = createCommands( + deps, + plan, + active, + publish, + wake, + ); + let saveScopedChoice = createScopedSave(deps, plan, active, publish); + let researchConsent = createResearchConsent(deps, plan, active, publish, markApplied, report); + let run = createRun(deps, plan, active, controller, publish, recover, drainEffects); + + function wake(): void { + if (!active()) return; + if (running) { + wakeRequested = true; + return; + } + let blocked = false; + let waitingForMirror = false; + running = Promise.resolve().then(() => run()).then(waiting => { + waitingForMirror = waiting; + }).catch(error => { + blocked = true; + report(error); + }).finally(() => { + running = undefined; + let requested = wakeRequested; + wakeRequested = false; + if ( + !blocked && active() + && ( + requested + || !waitingForMirror + && plan.conversationPlan.queue.some(item => item.status === "pending") + ) + ) wake(); + }); + } + + function stop(): void { + stopped = true; + controller.abort(); + } + + function track(action: (...args: Args) => Promise) { + return (...args: Args): Promise => { + let task = action(...args); + commands.add(task); + let settled = () => commands.delete(task); + void task.then(settled, settled); + return task; + }; + } + + /** Drain admitted commands and effects before closing their document. */ + async function idle(): Promise { + let tasks = [...commands, ...(running ? [running] : [])]; + while (tasks.length > 0) { + await Promise.allSettled(tasks); + tasks = [...commands, ...(running ? [running] : [])]; + } + } + + return { + accept: track(accept), + afterMessage, + correct: track(correct), + saveScopedChoice: track(saveScopedChoice), + researchConsent: track(researchConsent), + record: track(record), + retry: track(retry), + setEffects, + wake, + stop, + idle, + }; +} diff --git a/apps/server/src/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/questions/conversation-capacity.test.ts b/apps/server/src/questions/conversation-capacity.test.ts new file mode 100644 index 00000000..78b99489 --- /dev/null +++ b/apps/server/src/questions/conversation-capacity.test.ts @@ -0,0 +1,314 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Questions from "./service"; +import * as Store from "./store"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlan } from "../testing/plan"; +import { createProcessor } from "../conversation-plan/service"; +import { mirrorCard } from "../conversation-plan/card-mirror-runner"; +import { MAX_EVENTS } from "../conversation-plan/validation"; +import { applyEvent } from "../conversation-plan/events"; +import { opened, unlinked } from "../conversation-plan/service.test-fixtures"; +import type { Socket } from "../wire"; + +async function ask(context: Awaited>) { + let created = Promise.withResolvers(); + let waiting = Questions.ask( + context.plan, + context.server, + context.plan.id, + Questions.identify({ + questions: [{ + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [{ label: "Managed service", description: "" }], + }], + }), + undefined, + created.resolve, + ); + await Promise.race([created.promise, waiting]); + return [...context.plan.records.values()].at(-1)!; +} + +function fill(context: Awaited>, remaining: number) { + // Restoration replays all 4096 events, so keep filler IDs short and leave card threads untouched. + let threadId = "filler"; + let state = applyEvent(context.plan.conversationPlan, { + id: "filler:opened", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 0, + question: "Filler thread?", + }); + let thread = state.threads.at(-1)!; + while (state.events.length < MAX_EVENTS - remaining) { + state.events.push({ + id: String(state.events.length), + type: "thread.discarded", + threadId: thread.id, + observedThreadVersion: thread.version++, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: state.events.length, + }); + thread.status = "discarded"; + state.revision++; + } + context.plan.conversationPlan = state; +} + +function member(context: Awaited>) { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: context.plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +async function select(context: Awaited>, ws: Socket, id: string) { + let entry = Store.get(context.plan.questions, id)!; + let model = entry.model.fork(); + let question = entry.definition.questions[0]!; + model.api.val([question.id, "choice"]).set(question.options[0]!.id); + let patch = model.api.flush()!; + await Questions.edit(context.plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); +} + +for (let action of ["submit", "cancel"] as const) { + test(`full event history rejects linked ${action} without changing authority or draft`, async () => { + let context = await openPlan(); + try { + let record = await ask(context); + let { ws, frames } = member(context); + if (action === "submit") await select(context, ws, record.id); + let accepted = context.plan.records.get(record.id); + fill(context, 0); + await Plan.persist(context.plan); + let source = Room.project(context.plan.document); + let epoch = context.plan.document.epoch; + let seq = context.plan.document.seq; + let draft = Store.snapshot(context.plan.questions, record.id); + let revision = context.plan.persistence.revision; + frames.length = 0; + if (action === "submit") { + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: action, + id: record.id, + revision: Store.get(context.plan.questions, record.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: action, + id: record.id, + }); + } + expect(context.plan.records.get(record.id)).toEqual(accepted); + expect(Store.snapshot(context.plan.questions, record.id)).toEqual(draft); + expect(Store.get(context.plan.questions, record.id)?.claim).toBeUndefined(); + expect(Room.project(context.plan.document)).toBe(source); + expect([context.plan.document.epoch, context.plan.document.seq]).toEqual([epoch, seq]); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.persistence.revision).toBe(revision); + expect(frames).toMatchObject([{ + kind: "session:error", + rid: action, + message: "Conversation history is full", + }]); + } finally { + await Plan.close(context.plan); + } + }); +} + +for (let action of ["submit", "cancel"] as const) { + test(`the last event slot is reserved for ${action} across restart`, async () => { + let context = await openPlan(); + let errors: unknown[] = []; + let processor: ReturnType | undefined; + try { + let first = await ask(context); + let second = await ask(context); + let { ws, frames } = member(context); + if (action === "submit") await select(context, ws, first.id); + fill(context, 1); + if (action === "submit") { + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: "first", + id: first.id, + revision: Store.get(context.plan.questions, first.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "first", + id: first.id, + }); + } + expect(context.plan.pendingCardActions).toHaveLength(1); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "second", + id: second.id, + }); + expect(context.plan.records.get(second.id)?.status).toBe("open"); + expect(context.plan.pendingCardActions).toHaveLength(1); + expect(frames.at(-1)).toMatchObject({ kind: "session:error", rid: "second" }); + processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + onError: error => errors.push(error), + interpret: async input => ({ + ...unlinked(), + events: [opened(input.message)], + analysis: { ...unlinked().analysis, status: "applied" }, + }), + }); + let previous = context.plan.conversationPlan; + let fatal = spyOn(context.plan.persistence, "fatal"); + try { + await expect(processor.record({ + id: "unrelated", + type: "thread.opened", + threadId: "unrelated", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 0, + question: "Another question?", + })).rejects.toThrow("Conversation history is full"); + expect(context.plan.conversationPlan).toBe(previous); + expect(fatal).not.toHaveBeenCalled(); + } finally { + fatal.mockRestore(); + } + await processor.accept({ + id: "m-capacity", + text: "Can we ship?", + ts: 1000, + author: { kind: "member", handle: "ana" }, + }); + processor.afterMessage(); + await processor.idle(); + expect(errors).toEqual([]); + expect(context.plan.conversationPlan.queue).toMatchObject([{ + messageId: "m-capacity", + status: "failed", + }]); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 1); + let unlinkedCard = await ask(context); + expect(unlinkedCard.threadId).toBeUndefined(); + processor.stop(); + await processor.idle(); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.get(unlinkedCard.id)?.threadId).toBeUndefined(); + processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + interpret: async input => ({ + ...unlinked(), + events: [opened(input.message)], + analysis: { ...unlinked().analysis, status: "applied" }, + }), + }); + await mirrorCard(context.plan, processor); + expect(context.plan.pendingCardActions).toEqual([]); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS); + expect(context.plan.conversationPlan.events.at(-1)?.id).toBe( + action === "submit" ? `card:${first.id}:decided:1` : `card:${first.id}:discarded`, + ); + await processor.accept({ + id: "m-full", + text: "Can we ship?", + ts: 1001, + author: { kind: "member", handle: "ana" }, + }); + processor.afterMessage(); + await processor.idle(); + expect(context.plan.conversationPlan.queue.at(-1)).toMatchObject({ + messageId: "m-full", + status: "failed", + error: "Conversation history is full", + }); + if (action === "submit") { + await select(context, ws, unlinkedCard.id); + await Questions.submit(context.plan, context.server, context.plan.id, ws, { + kind: "question:submit", + ts: 0, + rid: "unlinked", + id: unlinkedCard.id, + revision: Store.get(context.plan.questions, unlinkedCard.id)!.revision, + }); + } else { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "unlinked", + id: unlinkedCard.id, + }); + } + expect(context.plan.records.get(unlinkedCard.id)?.status).toBe( + action === "submit" ? "answered" : "cancelled", + ); + expect(context.plan.pendingCardActions).toEqual([]); + } finally { + processor?.stop(); + await processor?.idle(); + await Plan.close(context.plan); + } + }, 20_000); +} + +test("ordinary asks and startup backfill preserve reserved mirror slots", async () => { + let context = await openPlan(); + try { + let first = await ask(context); + let { ws } = member(context); + fill(context, 3); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: first.id, + }); + let ordinary = await ask(context); + expect(ordinary.threadId).toBeUndefined(); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 3); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.get(ordinary.id)?.threadId).toBeUndefined(); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS - 3); + expect(context.plan.pendingCardActions).toHaveLength(1); + } finally { + await Plan.close(context.plan); + } +}, 20_000); diff --git a/apps/server/src/questions/conversation-card-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/planner-lifecycle-durability.test.ts b/apps/server/src/questions/planner-lifecycle-durability.test.ts new file mode 100644 index 00000000..c1436c96 --- /dev/null +++ b/apps/server/src/questions/planner-lifecycle-durability.test.ts @@ -0,0 +1,242 @@ +import { expect, spyOn, test } from "bun:test"; +import * as Questions from "./service"; +import * as Store from "./store"; +import * as Plan from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlan } from "../testing/plan"; +import { createProcessor } from "../conversation-plan/service"; +import { mirrorCard } from "../conversation-plan/card-mirror-runner"; +import { replay } from "../conversation-plan/domain"; +import { MAX_EVENTS } from "../conversation-plan/validation"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Socket } from "../wire"; + +function definition() { + return Questions.identify({ + questions: [{ + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [{ label: "Managed service", description: "" }], + }], + }); +} + +async function ask(context: Awaited>) { + let created = Promise.withResolvers(); + let waiting = Questions.ask( + context.plan, + context.server, + context.plan.id, + definition(), + undefined, + created.resolve, + ); + await Promise.race([created.promise, waiting]); + let record = [...context.plan.records.values()].at(-1)!; + return { record, waiting }; +} + +function member(context: Awaited>) { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: context.plan.id }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +test("ordinary Planner asks remain available after conversation thread capacity", async () => { + let context = await openPlan(); + try { + for (let index = 0; index < 21; index++) await ask(context); + expect(context.plan.records.size).toBe(21); + expect(context.plan.questions.open.size).toBe(21); + expect(context.plan.conversationPlan.threads).toHaveLength(20); + let last = [...context.plan.records.values()].at(-1)!; + expect(last.threadId).toBeUndefined(); + expect(Room.questionnaireProjections(context.plan.document).find(card => card.id === last.id)) + .not.toHaveProperty("thread"); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + expect(context.plan.records.size).toBe(21); + expect(context.plan.questions.open.size).toBe(21); + } finally { + await Plan.close(context.plan); + } +}); + +test("failed Planner ask commits leave no live or durable card or thread", async () => { + let context = await openPlan(); + let commit = spyOn(context.storage.collaboration, "commit") + .mockRejectedValueOnce(new Error("storage unavailable")); + try { + await expect(Questions.ask(context.plan, context.server, context.plan.id, definition())) + .rejects.toThrow("storage unavailable"); + expect(context.plan.records.size).toBe(0); + expect(context.plan.questions.open.size).toBe(0); + expect(context.plan.conversationPlan.threads).toEqual([]); + expect(Room.project(context.plan.document)).toBe(""); + expect(context.broadcasts).toEqual([]); + let saved = await context.storage.collaboration.load(context.plan.id, new Date()); + expect(saved?.sidecar ?? saved?.snapshot?.sidecar) + .toMatchObject({ questions: [], openQuestions: [] }); + } finally { + commit.mockRestore(); + await Plan.close(context.plan); + } +}); + +test("ordinary Planner asks remain available after conversation event capacity", async () => { + let context = await openPlan(); + try { + await ask(context); + let thread = context.plan.conversationPlan.threads[0]!; + let remaining = MAX_EVENTS - context.plan.conversationPlan.events.length; + let events: ConversationPlan.Event[] = Array.from({ length: remaining }, (_, index) => ({ + id: `discard-history:${index}`, + type: "thread.discarded", + threadId: thread.id, + observedThreadVersion: thread.version + index, + origin: "human", + actor: { kind: "member", handle: "ana" }, + at: index, + })); + context.plan.conversationPlan = replay([...context.plan.conversationPlan.events, ...events]); + let state = context.plan.conversationPlan; + let { record } = await ask(context); + expect(record.threadId).toBeUndefined(); + expect(context.plan.conversationPlan).toBe(state); + expect(context.plan.conversationPlan.events).toHaveLength(MAX_EVENTS); + expect(context.plan.records.size).toBe(2); + expect(context.plan.questions.open.size).toBe(2); + } finally { + await Plan.close(context.plan); + } +}, 20_000); + +test("cancellation keeps a linked draft private until commit and mirrors discard after restart", async () => { + let context = await openPlan(); + let { record, waiting } = await ask(context); + let { ws, frames } = member(context); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commit = context.storage.collaboration.commit.bind(context.storage.collaboration); + let held = spyOn(context.storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commit(input); + }); + let before = Room.project(context.plan.document); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let cancelled = Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: record.id, + }); + try { + await entered.promise; + expect(context.plan.records.get(record.id)?.status).toBe("open"); + expect(Store.snapshot(context.plan.questions, record.id).open).toBe(true); + expect(Room.project(context.plan.document)).toBe(before); + expect(context.plan.pendingCardActions).toEqual([]); + expect(events).toEqual([]); + expect(frames).toEqual([]); + release.resolve(); + await cancelled; + expect(await waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(context.plan.pendingCardActions).toMatchObject([{ + kind: "discarded", + cardId: record.id, + threadId: record.threadId, + actor: "ana", + }]); + expect(events).toMatchObject([{ kind: "discarded", id: record.id }]); + held.mockRestore(); + await Plan.close(context.plan); + context.plan = await Plan.open(context.plan.id, context.backend, context.server); + let processor = createProcessor({ + plan: context.plan, + active: () => true, + exclusive: action => Plan.exclusive(context.plan, action), + persist: () => Plan.persistExclusive(context.plan), + publish() {}, + }); + try { + await mirrorCard(context.plan, processor); + await mirrorCard(context.plan, processor); + expect(context.plan.pendingCardActions).toEqual([]); + expect( + context.plan.conversationPlan.threads.find(thread => thread.id === record.threadId) + ?.status, + ).toBe("discarded"); + expect( + context.plan.conversationPlan.events.filter(event => event.type === "thread.discarded"), + ) + .toHaveLength(1); + } finally { + processor.stop(); + await processor.idle(); + } + } finally { + release.resolve(); + await cancelled; + held.mockRestore(); + off(); + await Plan.close(context.plan); + } +}); + +test("failed cancellation releases its claim and preserves the accepted draft", async () => { + let context = await openPlan(); + let { record, waiting } = await ask(context); + let { ws, frames } = member(context); + let entry = Store.get(context.plan.questions, record.id)!; + let model = entry.model.fork(); + let question = entry.definition.questions[0]!; + model.api.val([question.id, "choice"]).set(question.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + await Questions.edit(context.plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id: record.id, + patch: [...patch.toBinary()], + }); + let draft = Store.snapshot(context.plan.questions, record.id); + frames.length = 0; + let source = Room.project(context.plan.document); + let commit = spyOn(context.storage.collaboration, "commit") + .mockRejectedValueOnce(new Error("storage unavailable")); + try { + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "failed-cancel", + id: record.id, + }); + expect(context.plan.records.get(record.id)?.status).toBe("open"); + expect(Store.get(context.plan.questions, record.id)?.claim).toBeUndefined(); + expect(Store.snapshot(context.plan.questions, record.id)).toEqual(draft); + expect(Room.project(context.plan.document)).toBe(source); + expect(context.plan.pendingCardActions).toEqual([]); + expect(frames).toMatchObject([{ kind: "session:error", rid: "failed-cancel" }]); + await Questions.cancel(context.plan, context.server, context.plan.id, ws, { + kind: "question:cancel", + ts: 0, + rid: "retry-cancel", + id: record.id, + }); + expect(await waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(context.plan.pendingCardActions).toHaveLength(1); + } finally { + commit.mockRestore(); + await Plan.close(context.plan); + } +}); diff --git a/e2e/harness/scripted-context.ts b/e2e/harness/scripted-context.ts new file mode 100644 index 00000000..744495ff --- /dev/null +++ b/e2e/harness/scripted-context.ts @@ -0,0 +1,45 @@ +export function scriptContext(prompt: unknown) { + let text: unknown = prompt; + if (prompt && typeof prompt === "object" && "role" in prompt && prompt.role === "user") { + let content = "content" in prompt ? prompt.content : undefined; + text = Array.isArray(content) + ? content.map(part => { + if (!part || part.type !== "text" || typeof part.text !== "string") { + throw new Error("scripted Planner requires a text prompt"); + } + return part.text; + }).join("") + : content; + } + if (typeof text !== "string") throw new Error("scripted Planner requires a text prompt"); + let lines = text.split("\n"); + let header = /^\[Background job: (heading|refine|suggest|prose)\]$/.exec(lines[0] ?? ""); + if (!header || lines.filter(line => line.startsWith("[Background job:")).length !== 1) { + throw new Error("scripted Planner requires one exact background job header"); + } + let kind = header[1] as "heading" | "refine" | "suggest" | "prose"; + let cards = lines.filter(line => line.startsWith("Decision card")); + if (kind === "heading") { + if (cards.length) throw new Error("a heading prompt cannot name a decision card"); + return { kind, target: "document" }; + } + let target = cards.length === 1 + ? (kind === "prose" + ? /^Decision card id: ([^\s:]{1,200})$/.exec(cards[0]!) + : /^Decision card ([^\s:]{1,200}): "[^\r\n]*"$/.exec(cards[0]!))?.[1] + : undefined; + if (!target) throw new Error("scripted Planner requires one exact decision card target"); + return { kind, target }; +} + +export function readRevision(result: { output: unknown; isError?: boolean }): number { + if (result.isError || typeof result.output !== "string") { + throw new Error("read_plan must return successful JSON"); + } + let parsed: unknown = JSON.parse(result.output); + if ( + !parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("revision" in parsed) + || !Number.isSafeInteger(parsed.revision) || (parsed.revision as number) < 0 + ) throw new Error("read_plan returned an invalid revision"); + return parsed.revision as number; +} diff --git a/e2e/harness/scripted-planner.ts b/e2e/harness/scripted-planner.ts new file mode 100644 index 00000000..f5b987c9 --- /dev/null +++ b/e2e/harness/scripted-planner.ts @@ -0,0 +1,218 @@ +import { + held, + readScript, + substitute, +} from "../../apps/server/src/conversation-plan/scripted-script"; +import { readRevision, scriptContext } from "./scripted-context"; + +import type { + HarnessV1, + HarnessV1StreamPart, +} from "../../apps/server/node_modules/@ai-sdk/harness"; + +type Result = { toolCallId: string; toolName: string; output: unknown; isError?: boolean }; +type ToolResult = Extract; + +/** The SDK executes tools; this test driver supplies only the scripted model side. */ +export function createPromptScriptedHarness( + dir: string, + observe: { onResult?: (result: Result) => Promise | void } = {}, +) { + let tools: string[][] = []; + let calls: Array<{ toolCallId: string; toolName: string; input: unknown }> = []; + let results: Result[] = []; + let entered = Promise.withResolvers(); + let released = Promise.withResolvers(); + let starts = 0; + let destroyed = 0; + let destroyers = new Set<() => Promise>(); + let shuttingDown = false; + let shutdown: Promise | undefined; + let fake: HarnessV1 & { shutdown(): Promise } = { + specificationVersion: "harness-v1", + harnessId: "prompt-scripted-jobs", + builtinTools: {}, + async shutdown() { + shuttingDown = true; + await (shutdown ??= Promise.all([...destroyers].map(destroy => destroy()))); + }, + async doStart(start) { + if (shuttingDown) throw new Error("scripted Harness is shut down"); + let turns = new Set<() => void>(); + let tasks = new Set>(); + let destruction: Promise | undefined; + let track = (task: Promise) => { + tasks.add(task); + void task.then(() => tasks.delete(task), () => tasks.delete(task)); + }; + let drain = async () => { + while (tasks.size) await Promise.allSettled(tasks); + }; + let destroy = () => { + destruction ??= (async () => { + for (let finish of turns) finish(); + await drain(); + destroyed++; + destroyers.delete(destroy); + })(); + return destruction; + }; + destroyers.add(destroy); + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + if (destruction) throw new Error("scripted Harness session is destroyed"); + starts++; + tools.push(options.tools.map(spec => spec.name)); + let done = Promise.withResolvers(); + let pending = new Map void; + }>(); + let submissions = new Set>(); + let stop = new AbortController(); + let finished = false; + let current = () => !finished && !options.abortSignal?.aborted; + let finish = () => { + if (finished) return; + finished = true; + stop.abort(); + options.abortSignal?.removeEventListener("abort", finish); + turns.delete(finish); + for (let entry of pending.values()) entry.resolve(); + pending.clear(); + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + }; + let fail = (error: unknown) => { + if (finished) return; + options.emit({ type: "error", error }); + finish(); + }; + turns.add(finish); + options.abortSignal?.addEventListener("abort", finish, { once: true }); + if (options.abortSignal?.aborted) finish(); + let request = (name: string, input: unknown) => { + let toolCallId = crypto.randomUUID(); + let returned = Promise.withResolvers(); + pending.set(toolCallId, { name, resolve: returned.resolve }); + calls.push({ toolCallId, toolName: name, input }); + options.emit({ + type: "tool-call", + toolCallId, + toolName: name, + input: JSON.stringify(input), + }); + return returned.promise; + }; + let driving = (async () => { + try { + if (finished) return; + let { kind, target } = scriptContext(options.prompt); + let script = await readScript(dir, kind); + if (!current()) return finish(); + entered.resolve(); + let gate = await held(dir, kind, stop.signal); + if (gate === "timed-out") throw new Error("Scripted Planner job hold timed out."); + if (gate !== "released" || !current()) return finish(); + released.resolve(); + for (let call of script) { + if (!current()) return finish(); + let read = await request("read_plan", {}); + if (!read || !current()) return finish(); + let revision = readRevision(read); + await request(call.tool, substitute(call.args, revision, target)); + } + finish(); + } catch (error) { + fail(error); + } finally { + await Promise.allSettled(submissions); + done.resolve(); + } + })(); + track(driving); + return { + done: done.promise, + submitToolResult(value) { + let submission = (async () => { + let entry = pending.get(value.toolCallId); + if (!entry || !current()) return finish(); + let result: Result = { + toolCallId: value.toolCallId, + toolName: entry.name, + output: value.output, + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }; + try { + results.push(result); + await observe.onResult?.(result); + if (!current()) return finish(); + options.emit({ + type: "tool-result", + toolCallId: value.toolCallId, + toolName: entry.name, + result: value.output as ToolResult["result"], + ...(value.isError !== undefined ? { isError: value.isError } : {}), + }); + } catch (error) { + fail(error); + } finally { + pending.delete(value.toolCallId); + entry.resolve(result); + } + })(); + submissions.add(submission); + track(submission); + void submission.then( + () => submissions.delete(submission), + () => submissions.delete(submission), + ); + return submission; + }, + }; + }, + doDestroy: destroy, + async doCompact() { + throw new Error("scripted Harness does not support compaction"); + }, + async doContinueTurn() { + throw new Error("scripted Harness does not support continuing a turn"); + }, + async doSuspendTurn() { + throw new Error("scripted Harness does not support suspending a turn"); + }, + async doDetach() { + throw new Error("scripted Harness does not support detaching a turn"); + }, + async doStop() { + for (let finish of turns) finish(); + await drain(); + return { + type: "resume-session", + harnessId: "prompt-scripted-jobs", + specificationVersion: "harness-v1", + data: null, + }; + }, + }; + }, + }; + return { + fake, + tools, + calls, + results, + starts: () => starts, + destroyed: () => destroyed, + entered: entered.promise, + released: released.promise, + }; +}