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

= { plan?: P; opening?: Promise

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

( + room: OpeningRoom

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

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

(room: OpeningRoom

): Promise

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

( + room: OpeningRoom

| undefined, +): Promise

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

( + room: OpeningRoom

| undefined, +): Promise

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

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

{title}

+ +
+

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

+ {state.error &&

{state.error} +

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

Loading research…

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

Dismissed

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

Saving…

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

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

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

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

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

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

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

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

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

+ This document is read-only. +

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

+ Reconnect to save this choice. +

+ )} + {error &&

{error}

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

- {displayText(item.text)} -

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

+ {displayText(item.text)} +

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

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

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

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

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

Jev detected

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

“{quote}”

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

{outcome.gate}

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

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

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

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

+ {analysis.policyGate &&

Policy: {analysis.policyGate}

} + {analysis.quoteValidation && ( +

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

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

{pass.stage}

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

+ {row.label} +

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

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

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

+ Added to this card. +

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

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

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