diff --git a/src/components/organisms/Studio/hooks/useChat.ts b/src/components/organisms/Studio/hooks/useChat.ts index cd9da3a..8985af9 100644 --- a/src/components/organisms/Studio/hooks/useChat.ts +++ b/src/components/organisms/Studio/hooks/useChat.ts @@ -46,11 +46,16 @@ import { stoppedQueuedSwitch, switchEnded, endedSwitch, + switchLine, switchReply, unreachableSwitch, optionLabel, withApprovedPlan, + questionPause, + withQuestion, + answeredPause, type ApprovedPlan, + type Pause, type PlanState, } from '@/lib/studio/plan'; import { withAnswer } from '@/lib/studio/transcript'; @@ -280,12 +285,9 @@ export const useChat = (initial?: ChatSnapshot) => { // The pending pause's addressing (interrupt_id) with, for elicitations, // the normalized clarify that answerClarify() builds its reply and // status note from, and that skipClarify() reads `skippable` from. - // For plans, the plan that switchChoice() checks a pick against. - const pendingInterruptRef = useRef<{ - interruptId: string; - clarify?: Clarify; - plan?: PlanState; - } | null>(null); + // For plans, the plan that switchChoice() checks a pick against. A + // question a switch's replan asked carries the switch's waiting plan. + const pendingInterruptRef = useRef(null); // The latest plan this turn offered, or is waiting on a switch of. A // fresh plan.proposed carries its baseline (what the turn's first plan // held) and learns which choice its switch moved. A new turn clears it. @@ -692,20 +694,19 @@ export const useChat = (initial?: ChatSnapshot) => { // follows re-renders the card. break; case 'elicitation.request': { + // Also a question a switch's replan asks, which takes + // the waiting card's place and carries the switch. const clarify = clarifyFromFrame(frame); stall.stop(); grace.cancel(); - pendingInterruptRef.current = { - interruptId: clarify.interruptId, + pendingInterruptRef.current = questionPause( clarify, - }; - setState((prev) => ({ - ...prev, - ...NO_LINE, - thinking: false, - clarify, - })); + pendingInterruptRef.current, + ); + setState((prev) => + withQuestion({ ...prev, ...NO_LINE, thinking: false }, clarify), + ); break; } case 'suggestions': { @@ -1026,11 +1027,98 @@ export const useChat = (initial?: ChatSnapshot) => { setState((prev) => ({ ...prev, queued: prev.queued.filter((item) => item.id !== id) })); }, []); + // A switch's stream, from the pick (switchChoice) or from the answer to + // a question its replan asked (resume), while `waiting` is the pending + // plan. A fresh plan.proposed, its `switch_failed` included, replaces + // the card through the stream; after Stop the frames are still read, so + // the plan the harness re-offers on cancel ends the switch. A stream that + // ends without a plan (it closed, broke, sent `done` or an error, or was + // stopped) ends the card read-only, once, closing the turn's Activity + // entry (endedSwitch) and leaving the reply alone. `refuse` gives the card + // for a refusal, which stays up when live and is the read-only end + // otherwise; `unreached` and `unsent` give the live card for a request + // that never reached the harness and one stopped before it started. + // Without them a refusal or an unreached request ends as an error, and a + // Stop before the request started as stopped. + const streamSwitch = useCallback( + ( + turn: number, + waiting: PlanState, + start: (handlers: StreamHandlers, signal: AbortSignal) => Promise, + { + refuse, + unreached, + unsent, + }: { + refuse?: (refusal: StreamRefusal) => PlanState; + unreached?: () => PlanState; + unsent?: () => PlanState; + } = {}, + ) => { + // The read-only card a refusal that may have used up the pause + // ends on. + let refused: PlanState | null = null; + + stream(turn, start, { + onRefusal: + refuse && + ((refusal) => { + const card = refuse(refusal); + + if (card.live) { + offerPlan(card); + } else { + refused = card; + } + + return true; + }), + onUnreachable: + unreached && + (() => { + offerPlan(unreached()); + + return true; + }), + onEnd: ({ error, stopped, sent }) => { + // A fresh plan, a refusal or a question replaced the + // waiting card. + if (pendingInterruptRef.current?.plan !== waiting) { + return; + } + + if (!sent && unsent) { + offerPlan(unsent()); + + return; + } + + const { plan: ended, activity } = endedSwitch(waiting, { + error, + stopped, + refused, + }); + + pendingInterruptRef.current = null; + turnPlanRef.current = null; + setState((prev) => { + const after = switchEnded(prev, waiting, ended); + + return after === prev ? prev : endTurn(after, turn, activity); + }); + }, + }); + }, + [offerPlan, stream], + ); + // Continue a paused turn on /interrupt — shared by clarify answers, the // skip button and the plan actions. `answer` is what the user chose or // wrote, which enters the transcript. // A stale plan answer (an approve sent from an out-of-date card) is // refused with the current card, which replaces it to be answered again. + // The answer to a question a switch's replan asked puts the waiting card + // back and resumes as the switch (streamSwitch). const resume = useCallback( ( reply: ClarifyReply | Pick, @@ -1039,8 +1127,9 @@ export const useChat = (initial?: ChatSnapshot) => { ) => { const conversation = sessionRef.current; const pending = pendingInterruptRef.current; + const after = answeredPause(pending); - pendingInterruptRef.current = null; + pendingInterruptRef.current = after; setState((prev) => ({ // The decision shows on the status line; the user's answer @@ -1050,31 +1139,35 @@ export const useChat = (initial?: ChatSnapshot) => { messages: answer && pending ? withAnswer(prev.messages, answer) : prev.messages, thinking: true, clarify: null, - plan: null, + plan: after?.plan ?? null, })); if (!conversation || !pending) { return; } - stream( - turnCount.current, - (handlers, signal) => - runInterrupt(conversation, pending.interruptId, reply, handlers, signal), - { - onRefusal: (refusal) => { - const current = stalePlan(refusal, pending?.plan); + const start = (handlers: StreamHandlers, signal: AbortSignal) => + runInterrupt(conversation, pending.interruptId, reply, handlers, signal); - if (current) { - offerPlan(current); - } + if (after?.plan) { + streamSwitch(turnCount.current, after.plan, start); - return current !== null; - }, + return; + } + + stream(turnCount.current, start, { + onRefusal: (refusal) => { + const current = stalePlan(refusal, pending?.plan); + + if (current) { + offerPlan(current); + } + + return current !== null; }, - ); + }); }, - [offerPlan, stream], + [offerPlan, stream, streamSwitch], ); // Answer every question of the pending clarify at once, keyed by @@ -1141,17 +1234,16 @@ export const useChat = (initial?: ChatSnapshot) => { [resume], ); - // A scope decision's option, picked on the plan card. The card stays, - // its controls waiting, until the harness re-offers the plan with what - // changed, or with `switch_failed` saying why it did not apply. A - // refusal that leaves the pause alone keeps the card live and says why on - // the choice's row (refusedSwitch); a stale one (the plan moved on - // elsewhere) is answered with the current card, and any other ends the - // card read-only. So does a stream that ends without a plan (it closed, - // broke, sent `done` or an error, or was stopped), once, closing the - // turn's Activity entry (endedSwitch) and leaving the reply alone. A + // A scope choice's or planner decision's option, picked on the plan card. + // The card stays, its controls waiting, until the harness re-offers the + // plan with what changed, or with `switch_failed` saying why it did not + // apply. The page sets no deadline of its own, since a planner + // decision's replan takes tens of seconds. A refusal that leaves the + // pause alone keeps the card live and says why on the choice's row + // (refusedSwitch); a stale one (the plan moved on elsewhere) is answered + // with the current card, and any other ends the card read-only. A // request that never reached the harness keeps the card live, with "Try - // again". + // again". The rest of the switch's end is streamSwitch's. const switchChoice = useCallback( (choiceId: string, optionId: string) => { const conversation = sessionRef.current; @@ -1165,9 +1257,6 @@ export const useChat = (initial?: ChatSnapshot) => { const label = optionLabel(plan.decisions, choiceId, optionId); const turn = turnCount.current; - // The read-only card a refusal that may have used up the pause - // ends on. - let refused: PlanState | null = null; // The pause stays pending with the waiting plan. switchReply // refuses a second pick while it waits, and the plan the harness @@ -1177,72 +1266,36 @@ export const useChat = (initial?: ChatSnapshot) => { pendingInterruptRef.current = { interruptId: pending.interruptId, plan: waiting }; turnPlanRef.current = waiting; setState((prev) => ({ - ...withLine(prev, label ? `Switching to ${label}` : 'Switching'), + ...withLine(prev, switchLine(choiceId, label)), thinking: true, plan: waiting, })); - stream( + streamSwitch( turn, + waiting, (handlers, signal) => runInterrupt(conversation, pending.interruptId, reply, handlers, signal), { - onRefusal: (refusal) => { - const card = refusedSwitch(waiting, refusal); - - if (card.live) { - offerPlan(card); - } else { - refused = card; - } - + refuse: (refusal) => { // The card shows only a harness ToolError's detail. if (!refusal.error && refusal.detail) { console.warn('Studio: a switch was refused', refusal); } - return true; + return refusedSwitch(waiting, refusal); }, // The request never reached the harness, so the pause // waits. - onUnreachable: () => { - offerPlan(unreachableSwitch(waiting)); - - return true; - }, - onEnd: ({ error, stopped, sent }) => { - // A fresh plan or a refusal replaced the waiting card. - if (pendingInterruptRef.current?.plan !== waiting) { - return; - } - - // Stopped while it waited behind the stream that - // offered the plan. Nothing reached the harness, so - // the pause waits. - if (!sent) { - offerPlan(stoppedQueuedSwitch(waiting)); - - return; - } - - const { plan: ended, activity } = endedSwitch(waiting, { - error, - stopped, - refused, - }); - - pendingInterruptRef.current = null; - turnPlanRef.current = null; - setState((prev) => { - const after = switchEnded(prev, waiting, ended); - - return after === prev ? prev : endTurn(after, turn, activity); - }); - }, + unreached: () => unreachableSwitch(waiting), + // Stopped while it waited behind the stream that offered + // the plan. Nothing reached the harness, so the pause + // waits. + unsent: () => stoppedQueuedSwitch(waiting), }, ); }, - [offerPlan, stream], + [streamSwitch], ); // A clicked `apply_choice` chip runs a turn with the chip's choice and no diff --git a/src/components/organisms/Studio/workspace/PlanCard.tsx b/src/components/organisms/Studio/workspace/PlanCard.tsx index fbd0a24..3fa6587 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.tsx +++ b/src/components/organisms/Studio/workspace/PlanCard.tsx @@ -21,9 +21,10 @@ type PlanCardProps = { /** * The research plan awaiting review, in the chat foot: the title and its - * summary, the scope choices ("Counted as", each switchable in place), the - * step that gives the answer, what to keep in mind about the answer, and - * "View full plan" to every step in a modal. Then the "Start building?" bar + * summary, the scope choices ("Counted as") and planner decisions ("Choices + * the plan made"), each switchable in place, the step that gives the + * answer, what to keep in mind about the answer, and "View full plan" to + * every step in a modal. Then the "Start building?" bar * with Cancel and Approve. Changes travel through the chat input below * (ChatPanel repurposes it as "Propose changes or ask a follow up…" while * the plan is pending, wired to the amend decision). diff --git a/src/components/organisms/Studio/workspace/PlanLedger.module.scss b/src/components/organisms/Studio/workspace/PlanLedger.module.scss index 7e9f169..57fc685 100644 --- a/src/components/organisms/Studio/workspace/PlanLedger.module.scss +++ b/src/components/organisms/Studio/workspace/PlanLedger.module.scss @@ -35,7 +35,7 @@ vertical-align: rem(1); } -// --- "Counted as": the scope choices as a ruled ledger ---------------------- +// --- The switchable choices as a ruled ledger, one head a group ------------ // A host sets --ledger-inset to align the rows with its own padding. .ledger { @@ -51,6 +51,11 @@ letter-spacing: -0.02em; } +// A group's head after another group's rows. +.row + .head { + border-top: 1px solid var(--color-net-light); +} + .row { display: grid; grid-template-columns: minmax(0, 1fr) auto; diff --git a/src/components/organisms/Studio/workspace/PlanLedger.tsx b/src/components/organisms/Studio/workspace/PlanLedger.tsx index 4f5fcfe..92a7c9d 100644 --- a/src/components/organisms/Studio/workspace/PlanLedger.tsx +++ b/src/components/organisms/Studio/workspace/PlanLedger.tsx @@ -1,7 +1,7 @@ 'use client'; import classNames from 'classnames'; -import { useEffect, useRef, useState, type RefObject } from 'react'; +import { Fragment, useEffect, useRef, useState, type RefObject } from 'react'; import styles from './PlanLedger.module.scss'; @@ -9,7 +9,9 @@ import { canSwitch, isActiveOption, isSelectable, + ledgerSections, stepCount, + switchPendingText, switchReply, yearsText, type PlanChoiceView, @@ -99,15 +101,16 @@ const NoticeRegion = ({ notice, onRetry }: { notice: PlanNotice | null; onRetry? ); /** - * One scope choice: its name, the value the plan counts by, "Changed" and - * the value it held when a switch moved it, and "Change", which opens the - * options in place, drawn as a radio list: buttons in a group, the active - * one pressed, each picking its option at once. A partial option names the - * years it covers and an unavailable one says why. While a switch is in - * flight every option ignores clicks but keeps focus (`aria-disabled`), and - * the picked one says the plan is updating. When the plan this row's switch - * produced arrives and the option that held focus is gone, focus moves to - * "Change". + * One scope choice or planner decision: its name, the value the plan holds, + * "Changed" and the value it held when a switch moved it, and "Change", + * which opens the options in place, drawn as a radio list: buttons in a + * group, the active one pressed, each picking its option at once. A partial + * option names the years it covers and an unavailable one says why. While a + * switch is in flight every option ignores clicks but keeps focus + * (`aria-disabled`), and the picked one says the plan is updating, or + * replanning on a planner decision, until the harness answers. When the plan + * this row's switch produced arrives and the option that held focus is gone, + * focus moves to "Change". */ const ChoiceRow = ({ choice, @@ -224,7 +227,7 @@ const ChoiceRow = ({ {isPending && ( - Updating plan + {switchPendingText(choiceId)} )} @@ -238,9 +241,11 @@ const ChoiceRow = ({ }; /** - * "Counted as": the plan's scope choices as a ruled ledger, one row each, - * then a line for each choice the switch moved that has no row. `plan` and - * `onSwitch` make the rows switchable. An approved plan passes neither. + * The plan's choices as a ruled ledger, one row each, in groups + * with their own heads (ledgerSections): "Counted as", the scope choices and + * a line for each choice the switch moved that has no row, then "Choices the + * plan made", the planner decisions. `plan` and `onSwitch` make the rows + * switchable. An approved plan passes neither. * `reasons` adds why the plan holds each choice. One row opens at a time: * the one being switched, else the one whose switch failed, until the user * opens or closes another. @@ -283,7 +288,8 @@ export const PlanLedger = ({ ? plan.notice : null; - const ruled = view.choices.length > 0 || view.moved.length > 0; + const sections = ledgerSections(view); + const ruled = sections.length > 0; // An approved plan without choices has nothing to show. A live plan keeps // its notice region mounted. @@ -303,27 +309,32 @@ export const PlanLedger = ({ } }} > - {ruled &&

Counted as

} - {view.choices.map((choice) => ( - - setPicked({ plan, open: open === choice.key ? null : choice.key }) - } - /> - ))} - {view.moved.map((move) => ( -

- - Changed from {move.from} to {move.to}.{move.reason && ` ${move.reason}`} - -

+ {sections.map((section) => ( + +

{section.head}

+ {section.choices.map((choice) => ( + + setPicked({ plan, open: open === choice.key ? null : choice.key }) + } + /> + ))} + {section.moved.map((move) => ( +

+ + Changed from {move.from} to {move.to}. + {move.reason && ` ${move.reason}`} + +

+ ))} +
))}
diff --git a/src/lib/studio/plan.ts b/src/lib/studio/plan.ts index 7a06207..3aa509f 100644 --- a/src/lib/studio/plan.ts +++ b/src/lib/studio/plan.ts @@ -1,10 +1,11 @@ /** - * The plan card's state and its `switch`: how a scope decision's options - * read, which of them the user can pick, the interrupt reply a pick sends, - * what the card says when a switch does not apply, and the approved plan the - * answer keeps. + * The plan card's state and its `switch`: how the options of a scope choice + * or a planner decision read, which of them the user can pick, the interrupt + * reply a pick sends, what the card says when a switch does not apply, and + * the approved plan the answer keeps. */ +import type { Clarify } from './clarify'; import type { StreamRefusal } from './studioClient'; import type { InterruptResponse, @@ -167,6 +168,27 @@ export const optionLabel = ( .find((decision) => decision.choice_id === choiceId) ?.options?.find((option) => option.option_id === optionId)?.label ?? null; +/** The `choice_id` prefix of a planner decision's row. Switching one replans + * with the user's pick held, which takes tens of seconds; switching a scope + * choice re-runs Search. */ +export const PLANNER_CHOICE_PREFIX = 'decision/'; + +/** The choice is a planner decision's row (PLANNER_CHOICE_PREFIX). */ +export const isPlannerChoice = (choiceId: string): boolean => + choiceId.startsWith(PLANNER_CHOICE_PREFIX); + +/** The chat line while a switch to `label` runs: "Switching to net + * disbursements", or "Replanning with top 10" on a planner decision. */ +export const switchLine = (choiceId: string, label: string | null): string => { + const [verb, link] = isPlannerChoice(choiceId) ? ['Replanning', 'with'] : ['Switching', 'to']; + + return label ? `${verb} ${link} ${label}` : verb; +}; + +/** What the picked option says while its switch runs. */ +export const switchPendingText = (choiceId: string): string => + isPlannerChoice(choiceId) ? 'Replanning' : 'Updating plan'; + /** The `switch_failed.code` of a switch the user stopped through `/cancel`, * re-offering the unchanged plan. */ export const SWITCH_CANCELLED = 'switch_cancelled'; @@ -463,11 +485,18 @@ const unappliedNotice = ( }); /** The sentence " The plan still counts gross disbursements." for the option - * the pick's choice holds, or nothing when the plan lacks the choice. */ + * the pick's choice holds (" The plan keeps top 5." on a planner decision), + * or nothing when the plan lacks the choice. */ const stillCounts = (plan: PlanState, pick: PlanSwitch): string => { const decision = plan.decisions.find((candidate) => candidate.choice_id === pick.choiceId); - return decision ? ` The plan still counts ${activeLabel(decision)}.` : ''; + if (!decision) { + return ''; + } + + const verb = isPlannerChoice(pick.choiceId) ? 'keeps' : 'still counts'; + + return ` The plan ${verb} ${activeLabel(decision)}.`; }; /** @@ -696,6 +725,48 @@ export const switchEnded = ( ended: PlanState, ): TState => (state.plan === waiting ? { ...state, plan: ended } : state); +/** + * The pause the turn waits on: its interrupt id, with the question + * (`clarify`) or the plan it asks the user to answer. `switch` is the + * waiting plan of a switch whose replan asked the question, so answering it + * resumes the switch. + */ +export type Pause = { + interruptId: string; + clarify?: Clarify; + plan?: PlanState; + switch?: PlanState; +}; + +/** The pause a planner question opens. A question asked while a switch + * waits (the pending plan is switching) carries the waiting plan. */ +export const questionPause = ( + clarify: Clarify, + pending: Pause | null, +): Pause & { clarify: Clarify } => { + const waiting = pending?.plan?.switching ? pending.plan : null; + + return { interruptId: clarify.interruptId, clarify, ...(waiting && { switch: waiting }) }; +}; + +/** The conversation while a question waits: the question takes the place + * of any plan card, including a switch's waiting card. */ +export const withQuestion = ( + state: TState, + clarify: Clarify, +): TState => ({ ...state, clarify, plan: null }); + +/** + * What stays pending once the user answers `pause`: for a question a + * switch's replan asked, the switch's waiting plan, which the card shows + * again, so a fresh plan, its `switch_failed`, a Stop, or another question + * still read as the switch's. The card is a new object, so the end of the + * stream that asked the question, which knew the old one, leaves it alone. + * Nothing for any other pause. + */ +export const answeredPause = (pause: Pause | null): Pause | null => + pause?.switch ? { interruptId: pause.interruptId, plan: { ...pause.switch } } : null; + /** The approved plan an answer keeps, from its `plan.approved` frame. */ export const approvedFromFrame = (frame: PlanApproved): ApprovedPlan => ({ plan_version_id: frame.plan_version_id, @@ -729,9 +800,10 @@ export type PlanStepView = { givesAnswer: boolean; }; -/** One scope choice as a row: its name, the value it holds, what it held - * before a switch, why the plan holds it, and the decision behind it when - * the row can switch. */ +/** One switchable choice as a row: its name, the value it holds, what it + * held before a switch, why the plan holds it, the decision behind it when + * the row can switch, and whether it is a planner decision rather than a + * scope choice (isPlannerChoice). */ export type PlanChoiceView = { key: string; name: string; @@ -740,6 +812,7 @@ export type PlanChoiceView = { changedReason: string | null; reason: string | null; decision: PlanDecision | null; + planner: boolean; }; /** A choice a switch moved that has no row: what it held, what it holds @@ -779,6 +852,7 @@ export const planView = (plan: PlanState): PlanView => { changedReason: change?.reason ?? null, reason: decision.reason || null, decision, + planner: isPlannerChoice(choiceId), }); } else { others.push({ @@ -835,6 +909,7 @@ export const approvedView = (plan: ApprovedPlan): PlanView => { changedReason: null, reason: null, decision: null, + planner: false, })), moved: [], others: [], @@ -843,6 +918,42 @@ export const approvedView = (plan: ApprovedPlan): PlanView => { }; }; +/** The ledger's head over the scope choices and the moves without a row. */ +export const SCOPE_HEAD = 'Counted as'; + +/** The ledger's head over the planner decisions the user can switch. */ +export const PLANNER_HEAD = 'Choices the plan made'; + +/** One group of the ledger: its head, its rows, and the moves listed under + * them. */ +export type LedgerSection = { + key: 'scope' | 'planner'; + head: string; + choices: PlanChoiceView[]; + moved: PlanMoveView[]; +}; + +/** + * The ledger's groups, each present only with something to show: the scope + * choices with the moves a switch made on choices without a row ("Counted + * as"), then the planner decisions ("Choices the plan made"). + */ +export const ledgerSections = (view: PlanView): LedgerSection[] => { + const scope = view.choices.filter((choice) => !choice.planner); + const planner = view.choices.filter((choice) => choice.planner); + const sections: LedgerSection[] = []; + + if (scope.length > 0 || view.moved.length > 0) { + sections.push({ key: 'scope', head: SCOPE_HEAD, choices: scope, moved: view.moved }); + } + + if (planner.length > 0) { + sections.push({ key: 'planner', head: PLANNER_HEAD, choices: planner, moved: [] }); + } + + return sections; +}; + export const STEPS_UPDATED = 'Steps updated to match.'; /** diff --git a/src/lib/studio/test/plan.test.ts b/src/lib/studio/test/plan.test.ts index 5fd6c1b..4d7b364 100644 --- a/src/lib/studio/test/plan.test.ts +++ b/src/lib/studio/test/plan.test.ts @@ -7,6 +7,7 @@ import test from 'node:test'; import { PLAN_REVISION_STALE, + answeredPause, approveReply, approvedFromFrame, approvedView, @@ -17,8 +18,11 @@ import { SWITCH_OPTION_UNAVAILABLE, isActiveOption, isSelectable, + ledgerSections, + PLANNER_HEAD, planFromFrame, planView, + questionPause, refusedSwitch, endedPlan, endedSwitch, @@ -27,14 +31,20 @@ import { startSwitch, stoppedQueuedSwitch, switchEnded, + switchLine, switchOutcome, + switchPendingText, switchReply, + SCOPE_HEAD, UNREACHABLE_NOTICE, unreachableSwitch, withApprovedPlan, + withQuestion, yearsText, + type Pause, type PlanState, } from '../plan.ts'; +import { clarifyFromFrame } from '../clarify.ts'; import { withoutChips } from '../chips.ts'; import type { StreamRefusal } from '../studioClient.ts'; import type { PlanApproved, PlanDecision, PlanProposed, RetryMode } from '../wire.ts'; @@ -1457,3 +1467,172 @@ test('withApprovedPlan leaves a transcript that does not end on a reply', () => test('yearsText joins ranges and prints a one-year range once', () => { assert.equal(yearsText(countDecision.options![1]!.years!), '2015–2019, 2022'); }); + +/** A planner decision the card can switch, as the harness sends it. */ +const donorsDecision: PlanDecision = { + id: 'd3', + question: 'How many donors?', + chosen: 'Top 5', + reason: 'a short list reads best', + resolved_by: 'default', + choice_id: 'decision/donor_count', + active_option_id: 'option_1', + options: [ + { option_id: 'option_1', label: 'Top 5', status: 'available', reason: null, years: null }, + { option_id: 'option_2', label: 'Top 10', status: 'available', reason: null, years: null }, + ], +}; + +const DONORS_PICK = { choiceId: 'decision/donor_count', optionId: 'option_2' }; + +test('the ledger heads scope choices and planner decisions apart', async (t) => { + const heads = (overrides: Partial) => + ledgerSections(planView(planFromFrame(frame(overrides)))).map((section) => [ + section.head, + section.choices.map((choice) => choice.key), + ]); + const cases: [string, Partial, [string, string[]][]][] = [ + [ + 'both', + { decisions: [countDecision, donorsDecision] }, + [ + [SCOPE_HEAD, ['spec_1:measure_basis']], + [PLANNER_HEAD, ['decision/donor_count']], + ], + ], + [ + 'planner decisions only', + { decisions: [donorsDecision] }, + [[PLANNER_HEAD, ['decision/donor_count']]], + ], + [ + 'scope choices only', + { decisions: [countDecision] }, + [[SCOPE_HEAD, ['spec_1:measure_basis']]], + ], + ]; + + for (const [name, overrides, expected] of cases) { + await t.test(name, () => { + assert.deepEqual(heads(overrides), expected); + }); + } +}); + +test("a planner decision's switch says it is replanning", () => { + assert.equal(switchPendingText(DONORS_PICK.choiceId), 'Replanning'); + assert.equal(switchLine(DONORS_PICK.choiceId, 'Top 10'), 'Replanning with Top 10'); + assert.equal(switchPendingText(PICK.choiceId), 'Updating plan'); + assert.equal(switchLine(PICK.choiceId, 'Net disbursements'), 'Switching to Net disbursements'); +}); + +test("a planner decision's switch that did not apply says the plan keeps its option", () => { + const waiting = startSwitch(planFromFrame(frame({ decisions: [donorsDecision] })), DONORS_PICK); + + assert.equal( + stoppedQueuedSwitch(waiting).notice?.text, + 'Stopped before switching to Top 10. The plan keeps Top 5.', + ); + assert.equal( + refusedSwitch(waiting, { status: 429, detail: null, error: null }).notice?.text, + 'Studio could not switch to Top 10. The plan keeps Top 5.', + ); +}); + +/** A one-question planner question on pause `interruptId`. */ +const question = (interruptId: string) => + clarifyFromFrame({ + event: 'elicitation.request', + seq: 5, + interrupt_id: interruptId, + questions: [ + { id: 'q1', question: 'Which year?', input: 'single_select', allow_free_text: true }, + ], + }); + +test("a question asked while a switch waits takes the card's place and carries the switch", () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const clarify = question('int-q'); + const pause = questionPause(clarify, { interruptId: waiting.interruptId, plan: waiting }); + + assert.equal(pause.interruptId, 'int-q'); + assert.equal(pause.clarify, clarify); + assert.equal(pause.switch, waiting); + + const screen = withQuestion({ ...pausedTurn(waiting), clarify: null }, clarify); + + assert.equal(screen.plan, null); + assert.equal(screen.clarify, clarify); +}); + +test('a question with no switch waiting carries none, and its answer leaves nothing pending', async (t) => { + const live = planFromFrame(frame()); + const cases: [string, Pause | null][] = [ + ['no pause', null], + ['a plan not switching', { interruptId: live.interruptId, plan: live }], + ['a question', questionPause(question('int-0'), null)], + ]; + + for (const [name, pending] of cases) { + await t.test(name, () => { + const pause = questionPause(question('int-q'), pending); + + assert.equal(pause.switch, undefined); + assert.equal(answeredPause(pause), null); + }); + } +}); + +test("answering a switch's question resumes the switch: its fresh plan, switch_failed and Stop end it", () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const pause = questionPause(question('int-q'), { interruptId: 'int-1', plan: waiting }); + const resumed = answeredPause(pause); + const card = resumed?.plan; + + assert.ok(card); + assert.equal(resumed?.interruptId, 'int-q'); + assert.deepEqual(card, waiting); + // A new object, so the stream that asked the question cannot end it. + assert.notEqual(card, waiting); + assert.deepEqual(card.switching, PICK); + + const before = pausedTurn(card); + + assert.equal(switchEnded(before, waiting, endedPlan(waiting)), before); + + // The fresh plan marks the switched choice. + assert.equal(planFromFrame(switchedFrame(), card).switched, PICK.choiceId); + + // The plan re-offered on cancel says why on the choice's row. + const reoffered = planFromFrame( + frame({ + interrupt_id: 'int-2', + switch_failed: { + choice_id: PICK.choiceId, + option_id: PICK.optionId, + code: SWITCH_CANCELLED, + reason: 'The switch was stopped. The plan still counts gross.', + retryable: true, + }, + }), + card, + ); + + assert.equal(reoffered.notice?.choiceId, PICK.choiceId); + assert.equal(reoffered.live, true); + + // A Stop with no plan re-offered ends the card read-only. + const { plan: ended, activity } = endedSwitch(card, { + error: null, + stopped: true, + refused: null, + }); + const after = switchEnded(before, card, ended); + + assert.equal(activity, 'skipped'); + assert.equal(after.plan?.live, false); + assert.equal(after.plan?.notice?.choiceId, PICK.choiceId); + + // A second question during the resumed switch carries it again. + assert.equal(questionPause(question('int-q2'), resumed).switch, card); +}); diff --git a/src/lib/studio/wire.ts b/src/lib/studio/wire.ts index 0bb51b4..a599710 100644 --- a/src/lib/studio/wire.ts +++ b/src/lib/studio/wire.ts @@ -311,9 +311,10 @@ export type PlanStep = { gives_answer?: boolean | null; }; -/** One way a scope decision can be counted. `partial` options cover only - * `years` (inclusive `[first, last]` ranges); `unavailable` ones say why in - * `reason`, reader copy written by the harness. */ +/** One option of a switchable decision. A scope choice's `partial` options + * cover only `years` (inclusive `[first, last]` ranges); `unavailable` ones + * say why in `reason`, reader copy written by the harness. A planner + * decision's options are all `available`. */ export type PlanDecisionOption = { option_id: string; label: string; @@ -329,12 +330,14 @@ export type PlanDecision = { reason: string; resolved_by: 'default' | 'user'; alternatives?: string[] | null; - // A scope decision the user can switch: the harness's ScopeChoice id, - // every option it offers, and the one the plan counts by now. A - // decision without `choice_id` is an ordinary planner decision. - // `choice_id` is built from the requirement and the axis, so it holds - // across a conversation's switches. An older harness built it from a - // spec id a switch can re-pin. + // A decision the user can switch: its row's id, every option it offers, + // and the one the plan counts by now. A scope choice's `choice_id` is + // built from the requirement and the axis, so it holds across a + // conversation's switches (an older harness built it from a spec id a + // switch can re-pin). A planner decision's is `decision/`, with + // option ids `option_` in its options' order, and switching it + // replans with the pick held. A decision without `choice_id` cannot be + // switched. choice_id?: string | null; options?: PlanDecisionOption[] | null; active_option_id?: string | null;