diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md index 63fee1600..10e8066b9 100644 --- a/fixtures/react-parity/runtime/README.md +++ b/fixtures/react-parity/runtime/README.md @@ -1,5 +1,45 @@ # Installed native runtime consumers +## Checkpoint review + +Open `/?checkpoints` on either installed review URL for the separate fixed +`checkpoint-thread` workflow. One application-owned session lives outside +component lifetime. React observes it with `useAgent` under StrictMode; Angular +uses `observeAgent` in its component injection context. Selected checkpoint +references and command outcome/completion counts are application state. The +session's execution position is private and is not invented as a snapshot field. + +Follow the sequence displayed in the view: **Load → Select A → Select B → Select +A → Fork selected → Select B → Continue branch → Load → Select P → Fork selected +→ Drop branch → Reconnect branch → Dispose → Continue branch → Fork selected**. +Selection performs no I/O and changes no transcript or values. Fork A reads the +exact completed source and confirms A1; continuation uses A1 even while B is +selected. The subsequent Load reads exact A2 and retains the earlier B/A/P +history page. Pending P rejects after its source read without a creation POST or +optimistic state change. Drop from A2 ends with physical status running; explicit +reconnect joins that same run at its cursor and confirms A3. Commands after +disposal resolve aborted without I/O. + +`checkpoint-execution.mjs` is an independent, bounded wire oracle. The server's +global latest remains B. It checks all 15 requests in order: one history POST, +six exact checkpoint-read POSTs, three creation POSTs, four physical status GETs +and one cursor join GET. Root checkpoint maps, input, catalog and stream modes +are exact; the installed SDK serializes join modes as one JSON query parameter. +Unexpected requests fail verification. The same six browser scenario groups run +in both package verifiers and the interactive review command, alongside the main +and thread workflows; scenario totals are derived from completed assertions. +Node oracle tests also reject original-A/selected-B continuation, missing routing, +wrong root maps/catalog/modes, extra POSTs and wrong join cursors/modes. + +The development factory exports its existing fixture checkpoint vocabulary and +adds a narrow `fork(checkpoint, input, options?)` return signature. Installed type +probes pass an observed readonly history reference directly, reject malformed +inputs and reserved routing, and check `Promise`. Its emitted +declaration uses installed core and fixture data types only, with no private +source or SDK references. This fixture changes no public API or production +branch UI. The strict HTTP fixture complements the separate real-server tests; +it does not establish general backend compatibility or cross-client atomicity. + ## Durable tool claims The private runtime can use an application-supplied execution store. Only a newly @@ -299,7 +339,7 @@ are missing. `node scripts/react-parity/review-runtime.mjs --help` prints the prerequisite and review sequence. The runner packs those artifacts, installs and strictly type-checks isolated -React and Angular consumers, builds each app, and runs all twenty-one browser +React and Angular consumers, builds each app, and runs all main, thread and checkpoint browser scenarios on fresh fixture servers. Only after those checks pass does it print two new, untouched loopback URLs. Open each URL manually; no browser opens automatically. The review servers have made no SDK requests at that point. @@ -485,7 +525,7 @@ React uses a Vite production build. Angular uses the existing consumer template' installed Angular CLI application builder and real APF linking, with output in `dist/consumer/browser` and input evidence from `dist/consumer/stats.json`. -Both built apps run the same twenty-one browser scenarios in installed Playwright +Both built apps run the same main and thread browser scenarios in installed Playwright Chromium: inert mount, explicit history load, equal history refresh, empty history replacement, successful text, a real local tool handler and exact two-request result continuation, protected visible server error, held streaming diff --git a/fixtures/react-parity/runtime/angular-checkpoints.ts b/fixtures/react-parity/runtime/angular-checkpoints.ts new file mode 100644 index 000000000..4db12d02e --- /dev/null +++ b/fixtures/react-parity/runtime/angular-checkpoints.ts @@ -0,0 +1,145 @@ +import { Component, signal } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { observeAgent } from '@threadplane/angular'; +import { createFixtureSession } from './runtime-entry.js'; +import { + checkpointInstructions, + display, + type FixtureCheckpoint, +} from './scenarios'; + +// Application ownership is independent of component creation and destruction. +let handlerCalls = 0; +const session = createFixtureSession('/api', 'checkpoint-thread', () => { + handlerCalls++; +}); + +@Component({ + selector: 'app-root', + template: ` +
+
+

Installed package review · Angular

+

Checkpoint review

+
+
+

Review sequence

+

{{ instructions }}

+
+
+

Application selection and session execution

+

+ The selected ID is local application state. The active execution + position is retained privately by the session after a confirmed + command; observed values below come from saved server state. +

+
+ + @for (id of ['A', 'B', 'P']; track id) { + + } + + + + + +
+
+ @for (field of fields(); track field[0]) { +
+

{{ field[1] }}

+ {{ + field[2] + }} +
+ } +
+
+
+ `, +}) +export class CheckpointApp { + readonly snapshot = observeAgent(session); + readonly instructions = checkpointInstructions; + readonly selected = signal(undefined); + readonly outcome = signal(''); + readonly finished = signal(0); + readonly owner = signal('active'); + readonly busy = signal(false); + reference(id: string) { + return this.snapshot().history?.find( + (entry) => entry.checkpoint.checkpoint_id === id + )?.checkpoint; + } + select(id: string) { + this.selected.set(this.reference(id)); + } + async command(action: () => Promise) { + this.busy.set(true); + this.outcome.set('running'); + try { + this.outcome.set((await action()) ?? 'loaded'); + } catch { + this.outcome.set('rejected'); + } finally { + this.finished.update((count) => count + 1); + this.busy.set(false); + } + } + load() { + return this.command(() => session.load!()); + } + fork() { + return this.command(() => session.fork(this.selected()!, 'Fork A')); + } + run(input: string) { + return this.command(() => session.submit(input)); + } + reconnect() { + return this.command(() => session.reconnect()); + } + async dispose() { + await session.dispose(); + this.owner.set('disposed'); + } + fields() { + const snapshot = this.snapshot(); + const view = display(snapshot); + return [ + [ + 'selected', + 'Selected checkpoint reference', + this.selected()?.checkpoint_id ?? 'none', + ], + ['owner', 'Application owner', this.owner()], + ['status', 'Session status', snapshot.status], + ['outcome', 'Last command outcome', this.outcome()], + ['finished', 'Completed commands', String(this.finished())], + ['text', 'Observed transcript', view.transcript], + ['values', 'Observed values', view.values], + ['history', 'Last loaded history page', view.history], + ['reconnect', 'Reconnect run', snapshot.reconnect?.runId ?? ''], + ['handlers', 'Tool handler calls', String(handlerCalls)], + ]; + } +} + +void bootstrapApplication(CheckpointApp); diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index efebd7da7..38cc1d980 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -360,5 +360,122 @@ "generatorsRun": [], "reason": "Only contributor fixture guidance, tests and private review infrastructure changed; no public docs/API/context generator inputs changed." }, - "verificationProvenance": "All listed commands ran in this increment. Runtime and native package production implementation/public exports remain unchanged. Historical foundation evidence remains unchanged. Parent audited spec, source, lifecycle, types, installed behavior and evidence. New independent subagent review was unavailable after earlier thread capacity exhaustion; hosted review must be inspected separately from job success." + "verificationProvenance": "All listed commands ran in this increment. Runtime and native package production implementation/public exports remain unchanged. Historical foundation evidence remains unchanged. Parent audited spec, source, lifecycle, types, installed behavior and evidence. New independent subagent review was unavailable after earlier thread capacity exhaustion; hosted review must be inspected separately from job success.", + "installedCheckpointReview": { + "recordScope": "This additive record covers only the September 24 installed checkpoint fixture milestone. The other top-level fields remain the historical September 22 thread-lifetime record, including its source fingerprint and manual browser claims.", + "observedOn": "2026-09-24", + "status": "local automated and manual verification passed; independent compliance and quality reviews approved; CI reported separately", + "verificationHead": "d3d8e018b31ca6ab71f238df38f8429e4c153765", + "integratedMain": "f4ccd582ccc0d36bcb3a6a0d51c0dfa8cb1ff33f", + "integrationEvidence": "PR #1156 required CI 36037710445 passed on verificationHead. Its main merge tree 51d158f52748566e8630bda74881ae9c66ecec0c is identical. Fixture changes were preserved byte-for-byte when moving onto that main commit.", + "workingTree": "Fixture-only changes on the reviewed production predecessor plus its recording-transport return-type follow-up; no production runtime, core, binding or dependency change in this milestone.", + "executed": [ + { + "log": "/tmp/installed-checkpoint-build.log", + "logSha256": "d301e3126fa75071ede57c077da114657206c2b396d986ac6c4f98bd85f322c4", + "command": "NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,content,react,angular --skip-nx-cache", + "exitCode": 0 + }, + { + "log": "/tmp/installed-checkpoint-node-final.log", + "logSha256": "f8f5893229b03f595345ab7ff29a7723fbf9b29710f56a1f8a4e5c89a9cb89e3", + "command": "node --test scripts/react-parity/*.spec.mjs", + "exitCode": 0, + "testsPassed": 329 + }, + { + "log": "/tmp/installed-checkpoint-react-restored.log", + "logSha256": "9c30fcfda00d425a46a19dbb16f35c8f2e0afb2cb4da434d2c936be12079149e", + "command": "node scripts/react-parity/verify-packages.mjs", + "exitCode": 0, + "browserScenarios": 27, + "checkpointScenarioGroups": 6, + "installedTypeProbes": true, + "productionBuild": true + }, + { + "log": "/tmp/installed-checkpoint-angular.log", + "logSha256": "d444c583c5324e61e8dd2f1f01698baa456867fa10cde3a3eff212bb710b3a7d", + "command": "node scripts/react-parity/verify-angular-package.mjs", + "exitCode": 0, + "browserScenarios": 27, + "checkpointScenarioGroups": 6, + "installedTypeProbes": true, + "productionBuild": true + } + ], + "testFirstEvidence": [ + { + "log": "/tmp/installed-checkpoint-types-red.log", + "logSha256": "9f52b6058e55809923f7f4e72a174cb58b45ee6fa89df963e9017e41f88653af", + "expectedFailure": "Readonly observed history checkpoint cannot call missing factory fork method." + }, + { + "log": "/tmp/installed-checkpoint-oracle-red.log", + "logSha256": "192e8c826cb661b95f2c16b328f82d991c333acf665fd3146683076afe15f246", + "expectedFailure": "Checkpoint-thread HTTP routes are not implemented." + }, + { + "log": "/tmp/installed-checkpoint-browser-red.log", + "logSha256": "26977eeae155251e4f3bdfc771d4dc3af84931e1a4a09c7db9243d9e29f41fc4", + "expectedFailure": "Installed checkpoint view does not exist." + }, + { + "log": "/tmp/installed-checkpoint-review-evidence-red.log", + "logSha256": "a6339c88470a344fad5bb48e8341bdb83578337bb8a0a19da2ed189716b0603c", + "expectedFailure": "Review provenance has no input hashes." + }, + { + "log": "/tmp/installed-checkpoint-review-evidence-green.log", + "logSha256": "8f1891e83b6bdeba32a4cb15539f46a116e90460dd7ab1f191db99f0f20f0d93", + "exitCode": 0 + } + ], + "semanticNegativeControl": { + "log": "/tmp/installed-checkpoint-negative-control.log", + "logSha256": "158ea6ee10872775772a06e74c5d2fb67b33748a175bcc326598acb45ddb56ef", + "mutation": "Temporarily change actual create-session stream routing from A1 to original A (ID and root map) for continuation.", + "expectedFailure": "Continue branch: wire oracle errors; exact branch creation routing, input, catalog and modes", + "restoration": "Original production file bytes restored in finally, empty production diff checked; restored React installed verifier passed." + }, + "checkpointProtocol": { + "thread": "checkpoint-thread", + "globalLatest": "B", + "historyPosts": 1, + "exactCheckpointReadPosts": 6, + "creationPosts": 3, + "physicalStatusGets": 4, + "cursorJoinGets": 1, + "fullSequenceAsserted": true, + "selectionImplicitIO": false, + "postDisposalIO": false, + "handlerCalls": 0, + "joinModesEncoding": "One JSON array query parameter, matching the installed SDK BaseClient." + }, + "declarations": "Readonly native-observer history reference passes directly to fork; outcome is Promise; malformed input and reserved config routing fail type probes; emitted declaration rejects SDK/private-source references.", + "cleanup": "Both package verifiers close page contexts, browsers, HTTP connections and temporary installed consumers in finally. No persistent manual servers were started by implementation.", + "manualBrowserReview": { + "performedBy": "Parent using Chrome DevTools MCP for React and Codex in-app browser for Angular, on fresh loopback review servers.", + "observedOn": "2026-09-24", + "reactUrl": "http://127.0.0.1:55315/?checkpoints", + "angularUrl": "http://127.0.0.1:55316/?checkpoints", + "sequence": "Load; Select A/B/A; Fork selected; Select B; Continue branch; Load; Select P; Fork selected; Drop branch; Reconnect branch; Dispose; Continue branch; Fork selected.", + "observations": "Both views retained branch A1/A2 despite selected B, rejected pending P without replacing A2, recovered run-A3 into A3, and returned aborted for both commands after disposal. Final selected P, owner disposed, session idle, completed commands 9, handlers 0; history remained B/A/P.", + "consoleWarnings": 0, + "consoleErrors": 0, + "reactNetwork": "Chrome recorded exactly 15 HTTP requests: one history POST, six checkpoint reads, three run creations, four status GETs and one run-A3 cursor join GET; all returned 200.", + "limits": "Angular network sequence is asserted by the installed automated oracle; its manual review claims visible state and console observations only. No live deployment, SSR or performance-budget claim.", + "runnerLog": "/tmp/installed-checkpoint-manual-runner.log", + "cleanup": "Owned review tabs closed and loopback ports 55315/55316 confirmed closed; user-owned manual sessions preserved." + }, + "independentReview": "Fresh compliance and quality reviewers independently approved the fixture diff; each ran 28 focused Node tests. Parent additionally ran all 329 parity-script tests, 829 runtime tests plus runtime/public type targets, inventory, source boundaries and version consistency. A whole-workspace emitted-boundary scan was not completed in this fixture worktree because the unchanged ag-ui built artifact was absent; installed declaration checks passed for both exercised consumers.", + "documentation": { + "generatorsRun": [], + "reason": "Only contributor fixtures and review infrastructure changed; no public API/docs/context generator inputs." + }, + "limits": [ + "Strict deterministic HTTP evidence complements separate real-server tests; it does not certify general LangGraph compatibility or cross-client atomicity.", + "No new public API, branch UI library, dependency update, package-root migration or release." + ] + } } diff --git a/fixtures/react-parity/runtime/react-checkpoints.tsx b/fixtures/react-parity/runtime/react-checkpoints.tsx new file mode 100644 index 000000000..fc10ede7e --- /dev/null +++ b/fixtures/react-parity/runtime/react-checkpoints.tsx @@ -0,0 +1,156 @@ +import { StrictMode, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useAgent } from '@threadplane/react'; +import { createFixtureSession } from './runtime-entry.js'; +import { + checkpointInstructions, + display, + type FixtureCheckpoint, +} from './scenarios'; +import './review.css'; + +// Application ownership is independent of the observer and StrictMode lifecycle. +let handlerCalls = 0; +const session = createFixtureSession('/api', 'checkpoint-thread', () => { + handlerCalls++; +}); + +export function CheckpointApp() { + const snapshot = useAgent(session); + const [selected, setSelected] = useState(); + const [outcome, setOutcome] = useState(''); + const [finished, setFinished] = useState(0); + const [owner, setOwner] = useState('active'); + const [busy, setBusy] = useState(false); + const view = display(snapshot); + const command = async (action: () => Promise) => { + setBusy(true); + setOutcome('running'); + try { + setOutcome((await action()) ?? 'loaded'); + } catch { + setOutcome('rejected'); + } finally { + setFinished((count) => count + 1); + setBusy(false); + } + }; + const dispose = async () => { + await session.dispose(); + setOwner('disposed'); + }; + const fields = [ + [ + 'selected', + 'Selected checkpoint reference', + selected?.checkpoint_id ?? 'none', + ], + ['owner', 'Application owner', owner], + ['status', 'Session status', snapshot.status], + ['outcome', 'Last command outcome', outcome], + ['finished', 'Completed commands', String(finished)], + ['text', 'Observed transcript', view.transcript], + ['values', 'Observed values', view.values], + ['history', 'Last loaded history page', view.history], + ['reconnect', 'Reconnect run', snapshot.reconnect?.runId ?? ''], + ['handlers', 'Tool handler calls', String(handlerCalls)], + ]; + return ( +
+
+

Installed package review · React

+

Checkpoint review

+
+
+

Review sequence

+

{checkpointInstructions}

+
+
+

Application selection and session execution

+

+ The selected ID is local application state. The active execution + position is retained privately by the session after a confirmed + command; observed values below come from saved server state. +

+
+ + {['A', 'B', 'P'].map((id) => ( + + ))} + + + + + +
+
+ {fields.map(([id, label, value]) => ( +
+

{label}

+ {value} +
+ ))} +
+
+
+ ); +} + +const container = document.getElementById('root'); +if (!container) throw new Error('Missing fixture root'); +createRoot(container).render( + + + +); diff --git a/fixtures/react-parity/runtime/runtime-entry.ts b/fixtures/react-parity/runtime/runtime-entry.ts index 3cf00f09c..a75437e79 100644 --- a/fixtures/react-parity/runtime/runtime-entry.ts +++ b/fixtures/react-parity/runtime/runtime-entry.ts @@ -6,6 +6,7 @@ import type { // eslint-disable-next-line @nx/enforce-module-boundaries -- This development-only entry composes private source into a temporary fixture bundle, never a package export. import { createSession } from '../../../libs/langgraph/src/runtime/create-session'; import type { + FixtureCheckpoint, FixtureRunOptions, FixtureSnapshot, FixtureSubmitInput, @@ -23,6 +24,11 @@ export function createFixtureSession( input: FixtureSubmitInput, options?: FixtureRunOptions ): Promise; + fork( + checkpoint: FixtureCheckpoint, + input: FixtureSubmitInput, + options?: FixtureRunOptions + ): Promise; load?: (options?: { signal?: AbortSignal }) => Promise; resume( value?: PlainValue, diff --git a/fixtures/react-parity/runtime/scenarios.ts b/fixtures/react-parity/runtime/scenarios.ts index 9a1dbe39a..de0637415 100644 --- a/fixtures/react-parity/runtime/scenarios.ts +++ b/fixtures/react-parity/runtime/scenarios.ts @@ -10,6 +10,9 @@ import type { export const reviewInstructions = 'Click Load three times: saved history, equal refresh, then empty history. Continue with Send → Tool → Error → Hold → Stop → Pause → Stop → Resume → Resume → Drop → Reconnect → Send. Tool and Drop send model, reasoning effort, UI mode and itinerary state once; displayed values come from the server. Resume first sends both approval responses, then confirms the final action. Drop loses observation of a running run; Reconnect joins that same run without another submission. Finish with Unmount → Dispose → Send after dispose → Resume after dispose → Reconnect after dispose in the owner controls below. Only three Load requests and one Drop are available per server; restart the review command to reset. Reloading the page does not reset server state.'; +export const checkpointInstructions = + 'Load → Select A → Select B → Select A → Fork selected → Select B → Continue branch → Load → Select P → Fork selected (rejected) → Drop branch → Reconnect branch → Dispose → Continue branch → Fork selected. Selection only chooses a saved reference for Fork selected. Continue and Load follow the session’s confirmed branch position even while B is selected; the global latest remains B. One bounded sequence is available per server; restart the review command to reset.'; + /** Fixture-local input contract uses only the installed neutral data vocabulary. */ export type FixtureInputState = Readonly> & { readonly messages?: never; @@ -96,7 +99,7 @@ type FixtureInterrupt = { readonly ns?: readonly string[]; }; -type FixtureCheckpoint = { +export type FixtureCheckpoint = { readonly thread_id: string; readonly checkpoint_ns: string; readonly checkpoint_id: string | null | undefined; diff --git a/scripts/react-parity/checkpoint-execution.mjs b/scripts/react-parity/checkpoint-execution.mjs new file mode 100644 index 000000000..c5c3c3ae9 --- /dev/null +++ b/scripts/react-parity/checkpoint-execution.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; + +const thread = 'checkpoint-thread'; +const root = `/api/threads/${thread}/`; +const modes = ['values', 'messages-tuple', 'updates', 'custom', 'checkpoints']; +const checkpoint = (id) => ({ thread_id: thread, checkpoint_ns: '', checkpoint_id: id, checkpoint_map: { '': id } }); +const human = (id, content) => ({ id, type: 'human', content }); +const assistant = (id, content) => ({ id, type: 'ai', content }); +const sse = (event, data, id) => `${id ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +const saved = (id, messages, parent = null, pending = false) => ({ + checkpoint: checkpoint(id), parent_checkpoint: parent ? checkpoint(parent) : null, + created_at: '2026-09-24T00:00:00Z', metadata: { run_id: `run-${id}` }, + values: { stage: id, messages }, next: pending ? ['approval'] : [], + tasks: pending ? [{ id: 'pending-approval', name: 'approval', error: null, result: null, interrupts: [{ id: 'approval', value: 'Approve P?' }] }] : [], +}); + +// Expectations are fixture-owned, never derived from a requested checkpoint, +// production helpers, selected UI state or the preceding request's routing. +const sequence = [ + ['POST', 'history', 'B/A/P'], + ['POST', 'state/checkpoint', 'A'], + ['POST', 'runs/stream', 'A', 'Fork A', 'A1'], + ['GET', 'runs/run-A1', 'success'], + ['POST', 'state/checkpoint', 'A1'], + ['POST', 'runs/stream', 'A1', 'Continue branch', 'A2'], + ['GET', 'runs/run-A2', 'success'], + ['POST', 'state/checkpoint', 'A2'], + ['POST', 'state/checkpoint', 'A2'], + ['POST', 'state/checkpoint', 'P'], + ['POST', 'runs/stream', 'A2', 'Drop branch', 'A3'], + ['GET', 'runs/run-A3', 'running'], + ['GET', 'runs/run-A3/stream', 'drop-cursor'], + ['GET', 'runs/run-A3', 'success'], + ['POST', 'state/checkpoint', 'A3'], +]; + +/** Independent bounded HTTP oracle; global latest B never follows the branch. */ +export function createCheckpointRoutes() { + const requests = []; + const a = saved('A', [human('a-user', 'Source A'), assistant('a-answer', 'Answer A')]); + const b = saved('B', [assistant('b-answer', 'Global B')], 'A'); + const p = saved('P', [assistant('p-answer', 'Pending P')], 'A', true); + const states = { A: a, B: b, P: p }; + const responses = new Set(); + const frame = (state) => sse('checkpoints', { + config: { configurable: { ...state.checkpoint, run_id: state.metadata.run_id } }, + values: state.values, next: state.next, tasks: state.tasks.map(({ id, name }) => ({ id, name })), + }, `cursor-${state.checkpoint.checkpoint_id}`); + return { + requests, + assertComplete() { assert.deepEqual(requests.map(({ method, path, target }) => [method, path, target]), sequence.map(step => step.slice(0, 3)), 'entire checkpoint request sequence'); }, + async handle(request, response, pathname) { + if (!pathname.startsWith(root)) return false; + const expected = sequence[requests.length]; + assert.ok(expected, 'no extra checkpoint operations'); + const [method, path, target, prompt, output] = expected; + const url = new URL(request.url, 'http://fixture'); + assert.equal(request.method, method, `checkpoint step ${requests.length}: method`); + assert.equal(pathname, root + path, `checkpoint step ${requests.length}: path`); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString(); + const body = raw ? JSON.parse(raw) : undefined; + let result; + let stream = false; + if (path.endsWith('/stream') && method === 'GET') { + assert.equal(body, undefined, 'join has no body'); + // The installed SDK BaseClient serializes array query values as JSON. + assert.deepEqual([...url.searchParams], [['cancel_on_disconnect', '0'], ['stream_mode', JSON.stringify(modes)]], 'exact checkpoint join modes'); + assert.equal(request.headers['last-event-id'], target, 'exact checkpoint join cursor'); + result = frame(states.A3); + stream = true; + } else { + assert.equal(url.search, '', 'no unexpected checkpoint query'); + if (path === 'history') { + assert.deepEqual(body, { limit: 10 }, 'bounded history request'); + result = [b, a, p]; + } else if (path === 'state/checkpoint') { + assert.deepEqual(body, { checkpoint: checkpoint(target) }, 'exact saved checkpoint read'); + assert.ok(states[target], 'saved checkpoint exists'); + result = states[target]; + } else if (path === 'runs/stream') { + const message = body?.input?.messages?.[0]; + assert.equal(typeof message?.id, 'string'); + assert.ok(message.id.length > 0); + assert.deepEqual(body, { + assistant_id: 'fixture-assistant', checkpoint: checkpoint(target), + input: { messages: [human(message.id, prompt)], client_tools: [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }] }, + stream_mode: modes, stream_subgraphs: true, stream_resumable: true, on_disconnect: 'continue', + }, 'exact branch creation routing, input, catalog and modes'); + const messages = [...states[target].values.messages, human(message.id, prompt), assistant(`answer-${output}`, output === 'A3' ? 'Branch partial recovered A3' : `Branch ${output}`)]; + states[output] = saved(output, messages, target); + result = output === 'A3' + ? sse('values', { stage: 'A2-running', messages: [...messages.slice(0, -1), assistant('answer-A3', 'Branch partial')] }, 'drop-cursor') + : frame(states[output]); + stream = true; + } else { + assert.equal(body, undefined, 'status has no body'); + result = { thread_id: thread, run_id: path.slice('runs/'.length), status: target }; + } + } + requests.push({ method, path, target, ...(body === undefined ? {} : { body }), ...(path.endsWith('/stream') && method === 'GET' ? { lastEventId: request.headers['last-event-id'], modes: url.searchParams.getAll('stream_mode') } : {}) }); + responses.add(response); + response.once('close', () => responses.delete(response)); + response.writeHead(200, stream ? { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', ...(output ? { 'content-location': `/threads/${thread}/runs/run-${output}` } : {}) } : { 'content-type': 'application/json' }); + response.end(stream ? result : JSON.stringify(result)); + return true; + }, + close() { for (const response of responses) response.destroy(); }, + }; +} + +/** Identical assertions run against both installed native observers. */ +export async function runCheckpointScenarios(page, server) { + await page.goto(`${server.url}/?checkpoints`); + const field = (id) => page.getByTestId(`checkpoint-${id}`); + const click = (name) => page.getByRole('button', { name, exact: true }).click(); + const command = async (label, count, outcome) => { + await click(label); + await expect(field('finished')).toHaveText(String(count)); + assert.deepEqual(server.errors.map(String), [], `${label}: wire oracle errors`); + await expect(field('outcome')).toHaveText(outcome); + }; + const observed = async () => Promise.all(['text', 'values', 'history', 'status'].map(id => field(id).textContent())); + await expect(field('status')).toHaveText('idle'); + await expect(field('text')).toHaveText(''); + await expect(field('values')).toHaveText('unobserved'); + await expect(field('history')).toHaveText('unobserved'); + await expect(field('selected')).toHaveText('none'); + await expect(field('finished')).toHaveText('0'); + assert.deepEqual(server.checkpoints.requests, [], 'checkpoint mount is inert'); + await command('Load', 1, 'loaded'); + await expect(field('text')).toHaveText('Global B'); + const history = await field('history').textContent(); + assert.deepEqual(JSON.parse(history).map(entry => entry.checkpoint.checkpoint_id), ['B', 'A', 'P']); + const loaded = await observed(); + for (const id of ['A', 'B', 'A']) { + await click(`Select ${id}`); + await expect(field('selected')).toHaveText(id); + assert.deepEqual(await observed(), loaded, 'selection leaves observed state untouched'); + } + assert.equal(server.checkpoints.requests.length, 1, 'selection performs no I/O'); + await expect(field('handlers')).toHaveText('0'); + + await command('Fork selected', 2, 'success'); + await expect(field('text')).toHaveText('Source A\nAnswer A\nFork A\nBranch A1'); + await expect(field('values')).toHaveText('{"stage":"A1"}'); + await expect(field('status')).toHaveText('idle'); + assert.equal(server.checkpoints.requests.length, 5); + + await click('Select B'); + await command('Continue branch', 3, 'success'); + await expect(field('selected')).toHaveText('B'); + await expect(field('text')).toHaveText('Source A\nAnswer A\nFork A\nBranch A1\nContinue branch\nBranch A2'); + await expect(field('values')).toHaveText('{"stage":"A2"}'); + await command('Load', 4, 'loaded'); + await expect(field('history')).toHaveText(history); + await expect(field('values')).toHaveText('{"stage":"A2"}'); + assert.equal(server.checkpoints.requests.length, 9); + + await click('Select P'); + const beforeRejected = await observed(); + await command('Fork selected', 5, 'rejected'); + assert.deepEqual(await observed(), beforeRejected, 'pending fork publishes no optimistic state'); + assert.equal(server.checkpoints.requests.length, 10); + + await command('Drop branch', 6, 'interrupted'); + await expect(field('status')).toHaveText('error'); + await expect(field('reconnect')).toHaveText('run-A3'); + await expect(field('text')).toContainText('Branch partial'); + assert.equal(server.checkpoints.requests.length, 12, 'no automatic reconnect'); + await command('Reconnect branch', 7, 'success'); + await expect(field('status')).toHaveText('idle'); + await expect(field('reconnect')).toHaveText(''); + await expect(field('values')).toHaveText('{"stage":"A3"}'); + await expect(field('text')).toHaveText('Source A\nAnswer A\nFork A\nBranch A1\nContinue branch\nBranch A2\nDrop branch\nBranch partial recovered A3'); + assert.equal((await field('text').innerText()).split('Branch partial').length - 1, 1); + + await click('Dispose'); + await expect(field('owner')).toHaveText('disposed'); + const disposed = await observed(); + await command('Continue branch', 8, 'aborted'); + await command('Fork selected', 9, 'aborted'); + assert.deepEqual(await observed(), disposed); + await expect(field('handlers')).toHaveText('0'); + server.checkpoints.assertComplete(); + assert.deepEqual(server.errors, []); + return ['checkpoint inert mount, explicit history and local selection', 'completed A fork and exact A1 confirmation', 'branch continuation and exact A2 load despite selected B', 'pending P rejection without optimistic publication', 'branch clean EOF and explicit cursor reconnect to A3', 'disposed checkpoint commands abort without I/O']; +} diff --git a/scripts/react-parity/checkpoint-execution.spec.mjs b/scripts/react-parity/checkpoint-execution.spec.mjs new file mode 100644 index 000000000..671ef4513 --- /dev/null +++ b/scripts/react-parity/checkpoint-execution.spec.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { serveRuntimeConsumer } from './runtime-consumer.mjs'; + +const ref = (id) => ({ thread_id: 'checkpoint-thread', checkpoint_ns: '', checkpoint_id: id, checkpoint_map: { '': id } }); +const modes = ['values', 'messages-tuple', 'updates', 'custom', 'checkpoints']; +const body = (id, content) => ({ + assistant_id: 'fixture-assistant', checkpoint: ref(id), + input: { messages: [{ id: `user-${content}`, type: 'human', content }], client_tools: [{ name: 'weather', description: 'Current weather' }, { name: 'count', description: 'Count values' }] }, + stream_mode: modes, stream_subgraphs: true, stream_resumable: true, on_disconnect: 'continue', +}); +const send = (server, path, method = 'GET', value, headers) => fetch(`${server.url}/api/threads/checkpoint-thread/${path}`, { method, headers, ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); +const steps = [ + ['history', 'POST', { limit: 10 }], + ['state/checkpoint', 'POST', { checkpoint: ref('A') }], + ['runs/stream', 'POST', body('A', 'Fork A')], + ['runs/run-A1'], + ['state/checkpoint', 'POST', { checkpoint: ref('A1') }], + ['runs/stream', 'POST', body('A1', 'Continue branch')], + ['runs/run-A2'], + ['state/checkpoint', 'POST', { checkpoint: ref('A2') }], + ['state/checkpoint', 'POST', { checkpoint: ref('A2') }], + ['state/checkpoint', 'POST', { checkpoint: ref('P') }], + ['runs/stream', 'POST', body('A2', 'Drop branch')], + ['runs/run-A3'], + [`runs/run-A3/stream?cancel_on_disconnect=0&stream_mode=${encodeURIComponent(JSON.stringify(modes))}`, 'GET', undefined, { 'Last-Event-ID': 'drop-cursor' }], + ['runs/run-A3'], + ['state/checkpoint', 'POST', { checkpoint: ref('A3') }], +]; +async function advance(server, count) { + const responses = []; + for (const step of steps.slice(0, count)) { + const response = await send(server, ...step); + const text = await response.text(); + assert.equal(response.status, 200, `${step[0]}: ${server.errors.map(String).join('; ')}`); + responses.push(text); + } + return responses; +} + +test('checkpoint wire fixture independently bounds complete fork, continuation, rejection and reconnect sequence', async () => { + const server = await serveRuntimeConsumer('/unused'); + try { + const responses = await advance(server, steps.length); + assert.deepEqual(JSON.parse(responses[0]).map(state => state.checkpoint.checkpoint_id), ['B', 'A', 'P']); + assert.equal(JSON.parse(responses[0])[0].values.messages[0].content, 'Global B'); + assert.match(responses[2], /event: checkpoints/); + assert.equal(JSON.parse(responses[4]).values.stage, 'A1'); + assert.equal(JSON.parse(responses[7]).values.stage, 'A2'); + assert.deepEqual(JSON.parse(responses[9]).next, ['approval']); + assert.equal(JSON.parse(responses[11]).status, 'running'); + assert.equal(JSON.parse(responses[13]).status, 'success'); + assert.equal(JSON.parse(responses[14]).values.stage, 'A3'); + server.checkpoints.assertComplete(); + assert.deepEqual(server.errors, []); + assert.deepEqual(server.requests, []); + assert.deepEqual(server.historyRequests, []); + assert.equal((await send(server, 'runs/stream', 'POST', body('A3', 'Continue branch'))).status, 500); + assert.equal(server.errors.length, 1); + } finally { await server.close(); } +}); + +for (const [label, prefix, request] of [ + ['wrong source read', 1, ['state/checkpoint', 'POST', { checkpoint: ref('B') }]], + ['missing routing', 2, ['runs/stream', 'POST', { ...body('A', 'Fork A'), checkpoint: undefined }]], + ['wrong root map', 2, ['runs/stream', 'POST', { ...body('A', 'Fork A'), checkpoint: { ...ref('A'), checkpoint_map: {} } }]], + ['missing checkpoint mode', 2, ['runs/stream', 'POST', { ...body('A', 'Fork A'), stream_mode: modes.slice(0, -1) }]], + ['wrong catalog', 2, ['runs/stream', 'POST', { ...body('A', 'Fork A'), input: { ...body('A', 'Fork A').input, client_tools: [] } }]], + ['continue routed to original A', 5, ['runs/stream', 'POST', body('A', 'Continue branch')]], + ['continue routed to selected B', 5, ['runs/stream', 'POST', body('B', 'Continue branch')]], + ['extra creation POST', 3, ['runs/stream', 'POST', body('A', 'Fork A')]], + ['wrong join cursor', 12, [steps[12][0], 'GET', undefined, { 'Last-Event-ID': 'wrong' }]], + ['missing join modes', 12, ['runs/run-A3/stream?cancel_on_disconnect=0', 'GET', undefined, { 'Last-Event-ID': 'drop-cursor' }]], + ['wrong join modes', 12, [`runs/run-A3/stream?cancel_on_disconnect=0&stream_mode=${encodeURIComponent(JSON.stringify(modes.slice(0, -1)))}`, 'GET', undefined, { 'Last-Event-ID': 'drop-cursor' }]], + ['wrong method', 0, ['history']], +]) { + test(`checkpoint oracle rejects ${label} without advancing`, async () => { + const server = await serveRuntimeConsumer('/unused'); + try { + await advance(server, prefix); + const response = await send(server, ...request); + await response.text(); + assert.equal(response.status, 500); + assert.equal(server.errors.length, 1); + assert.equal(server.checkpoints.requests.length, prefix); + } finally { await server.close(); } + }); +} diff --git a/scripts/react-parity/review-runtime.mjs b/scripts/react-parity/review-runtime.mjs index 98c8b144c..e862cc659 100644 --- a/scripts/react-parity/review-runtime.mjs +++ b/scripts/react-parity/review-runtime.mjs @@ -17,7 +17,7 @@ const script = fileURLToPath(import.meta.url); const buildCommand = 'NX_DAEMON=false npx nx run-many -t build -p core,angular,react --skip-nx-cache'; const manualOrder = - 'Use three Load clicks (saved, equal refresh, empty); Send → Tool → Error → Hold → Stop → Pause → Stop → Resume → Resume → Drop → Reconnect → Send → Unmount → Dispose → Send after dispose → Resume after dispose → Reconnect after dispose. Resume answers both approvals, then the final confirmation. Reconnect joins the dropped run without resubmitting. Open /?threads on either review URL for application-owned conversation selection; follow its separate sequence. Only bounded requests are available per server; restart this command for a fresh review. Reloading the page does not reset server state.'; + 'Use three Load clicks (saved, equal refresh, empty); Send → Tool → Error → Hold → Stop → Pause → Stop → Resume → Resume → Drop → Reconnect → Send → Unmount → Dispose → Send after dispose → Resume after dispose → Reconnect after dispose. Resume answers both approvals, then the final confirmation. Reconnect joins the dropped run without resubmitting. Open /?threads for application-owned conversation selection or /?checkpoints for completed checkpoint fork, continued branch, pending rejection and cursor reconnect; follow each view’s separate sequence. Only bounded requests are available per server; restart this command for a fresh review. Reloading the page does not reset server state.'; function prerequisites(root) { const missing = ['core', 'angular', 'react'].filter( @@ -40,10 +40,15 @@ function sourceProvenance(root) { 'fixtures/react-parity/runtime', 'scripts/react-parity/runtime-consumer.mjs', 'scripts/react-parity/thread-lifetime.mjs', + 'scripts/react-parity/checkpoint-execution.mjs', 'scripts/react-parity/review-runtime.mjs', ]; return { head: git('rev-parse', 'HEAD'), + inputs: [...new Set(git('ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...paths).split('\0').filter(Boolean))] + .filter(path => existsSync(join(root, path))) + .sort() + .map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(root, path))).digest('hex') })), trackedRuntimeFixtureStatus: git( 'status', '--short', diff --git a/scripts/react-parity/review-runtime.spec.mjs b/scripts/react-parity/review-runtime.spec.mjs index 4b168d70f..f873cb773 100644 --- a/scripts/react-parity/review-runtime.spec.mjs +++ b/scripts/react-parity/review-runtime.spec.mjs @@ -25,6 +25,10 @@ function fixtureRoot(t) { } const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + for (const path of ['scripts/react-parity/checkpoint-execution.mjs', 'fixtures/react-parity/runtime/scenarios.ts']) { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), `fixture ${path}`); + } git('init'); git( '-c', @@ -155,6 +159,10 @@ test('workers have isolated unset build environments, e2e runs before fresh manu }).trim() ); assert.equal(review.artifacts.length, 2); + assert.ok(Array.isArray(review.provenance.inputs), 'review provenance includes input hashes'); + for (const path of ['scripts/react-parity/checkpoint-execution.mjs', 'fixtures/react-parity/runtime/scenarios.ts']) { + assert.match(review.provenance.inputs.find(input => input.path === path)?.sha256 ?? '', /^[a-f0-9]{64}$/, `${path} has review input evidence`); + } assert.match(review.artifacts[0].sha256, /^[a-f0-9]{64}$/); assert.ok( logs.some( diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs index 959532a9f..efcf38653 100644 --- a/scripts/react-parity/runtime-consumer.mjs +++ b/scripts/react-parity/runtime-consumer.mjs @@ -8,6 +8,7 @@ import { build } from 'vite'; import ts from 'typescript'; import { chromium, expect } from '@playwright/test'; import { createThreadRoutes, runThreadScenarios } from './thread-lifetime.mjs'; +import { createCheckpointRoutes, runCheckpointScenarios } from './checkpoint-execution.mjs'; export function lockedReactManifest(lock) { const entries = (names) => Object.fromEntries(names.map((name) => { @@ -201,6 +202,21 @@ export function installedTypeSource(template, kind) { // @ts-expect-error Loaded pages remain readonly. history.pop(); const entry = history[0]; + const forked: Promise = session.fork(entry.checkpoint, 'Fork A', runOptions); + void forked; + void session.fork(entry.checkpoint, { message: 'Fork A', state: { color: 'blue' } } as const); + // @ts-expect-error Fork requires a checkpoint reference. + void session.fork('A', 'Fork A'); + // @ts-expect-error Fork input must be authored text/plain data. + void session.fork(entry.checkpoint, { message: 42 }); + // @ts-expect-error Fork cannot override thread routing. + void session.fork(entry.checkpoint, 'Fork A', { config: { configurable: { thread_id: 'other' } } }); + // @ts-expect-error Fork cannot override checkpoint routing. + void session.fork(entry.checkpoint, 'Fork A', { config: { configurable: { checkpoint_id: 'B' } } }); + // @ts-expect-error Fork cannot override root namespace. + void session.fork(entry.checkpoint, 'Fork A', { config: { configurable: { checkpoint_ns: 'child' } } }); + // @ts-expect-error Fork cannot override checkpoint map. + void session.fork(entry.checkpoint, 'Fork A', { config: { configurable: { checkpoint_map: {} } } }); const checkpointId: string | null | undefined = entry.checkpoint.checkpoint_id; const parentId: string | null | undefined = entry.parent_checkpoint?.checkpoint_id; const checkpointData: PlainValue = entry.checkpoint.checkpoint_map?.['branch']; @@ -331,15 +347,19 @@ export async function prepareRuntimeConsumer(root, consumer, kind) { const destination = kind === 'angular' ? join(consumer, 'src') : consumer; cpSync(join(temporary, 'bundle/runtime-entry.js'), join(destination, 'runtime-entry.js')); cpSync(join(temporary, 'types/fixtures/react-parity/runtime/runtime-entry.d.ts'), join(destination, 'runtime-entry.d.ts')); + const declaration = readFileSync(join(destination, 'runtime-entry.d.ts'), 'utf8'); + assert.doesNotMatch(declaration, /@langchain|libs\/|create-session/, 'emitted fixture declaration exposes no SDK/private references'); cpSync(join(fixture, 'scenarios.ts'), join(destination, 'scenarios.ts')); cpSync(join(fixture, 'thread-owner.ts'), join(destination, 'thread-owner.ts')); const threadView = `${kind}-threads.${kind === 'react' ? 'tsx' : 'ts'}`; cpSync(join(fixture, threadView), join(destination, threadView)); + const checkpointView = `${kind}-checkpoints.${kind === 'react' ? 'tsx' : 'ts'}`; + cpSync(join(fixture, checkpointView), join(destination, checkpointView)); cpSync(join(fixture, 'review.css'), join(destination, 'review.css')); const app = `${kind}-app.${kind === 'react' ? 'tsx' : 'ts'}`; cpSync(join(fixture, app), join(destination, app)); writeFileSync(join(destination, kind === 'react' ? 'main.tsx' : 'main.ts'), - `if (new URLSearchParams(location.search).has('threads')) {\n void import('./${kind}-threads');\n} else {\n void import('./${kind}-app');\n}\n`); + `if (new URLSearchParams(location.search).has('checkpoints')) {\n void import('./${kind}-checkpoints');\n} else if (new URLSearchParams(location.search).has('threads')) {\n void import('./${kind}-threads');\n} else {\n void import('./${kind}-app');\n}\n`); if (kind === 'angular') { const configPath = join(consumer, 'angular.json'); const config = JSON.parse(readFileSync(configPath, 'utf8')); @@ -359,6 +379,7 @@ export async function prepareRuntimeConsumer(root, consumer, kind) { /** Bounded fixture server: built files and deterministic history/run routes. */ export async function serveRuntimeConsumer(directory) { const threads = createThreadRoutes(); + const checkpoints = createCheckpointRoutes(); const requests = []; const historyRequests = []; const joinRequests = []; @@ -377,6 +398,7 @@ export async function serveRuntimeConsumer(directory) { const pathname = url.pathname; if (pathname.startsWith('/api/')) { if (await threads.handle(request, response, pathname)) return; + if (await checkpoints.handle(request, response, pathname)) return; const runPath = '/api/threads/fixture-thread/runs/drop-run'; if (pathname === runPath || pathname === `${runPath}/stream`) { assert.equal(request.method, 'GET', 'known-run recovery only performs GET'); @@ -447,9 +469,10 @@ export async function serveRuntimeConsumer(directory) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); return { - url: `http://127.0.0.1:${server.address().port}`, requests, historyRequests, joinRequests, statusRequests, errors, holdStarted, holdAborted, threads, + url: `http://127.0.0.1:${server.address().port}`, requests, historyRequests, joinRequests, statusRequests, errors, holdStarted, holdAborted, threads, checkpoints, async close() { threads.close(); + checkpoints.close(); for (const response of held) response.destroy(); const closed = once(server, 'close'); server.close(); @@ -724,6 +747,7 @@ export async function runRuntimeScenarios(directory, kind) { assert.deepEqual(server.historyRequests, [{ limit: 10 }, { limit: 10 }, { limit: 10 }], 'only explicit loads read history'); completed.push('unmount and explicit disposal'); completed.push(...await runThreadScenarios(page, server)); + completed.push(...await runCheckpointScenarios(page, server)); assert.deepEqual(server.errors.map(String), []); assert.deepEqual(pageErrors, []); assert.deepEqual(unexpected, []);