diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3be15d40f..dc27f3480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,7 @@ jobs: env: LIBS: chat,langgraph,ag-ui,render,a2ui,telemetry,middleware FOUNDATIONS: core,content,angular,react + THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT: /tmp/threadplane-agui-client-tools-native-fixture-env steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -188,6 +189,10 @@ jobs: run: node scripts/react-parity/review-native-ag-ui.mjs --verify - name: Verify real LangGraph candidate package run: node scripts/react-parity/verify-langgraph-candidate.mjs + - name: Prepare canonical AG-UI client-tools fixture + run: UV_PYTHON=3.12 UV_PROJECT_ENVIRONMENT="$THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT" uv sync --frozen --no-install-project --project cockpit/ag-ui/client-tools/python + - name: Verify canonical AG-UI client-tools fixtures + run: node --test scripts/react-parity/ag-ui-candidate-package.spec.mjs fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs - name: Verify real AG-UI candidate package run: node scripts/react-parity/verify-ag-ui-candidate.mjs - name: Verify installed owned Markdown diff --git a/fixtures/react-parity/ag-ui-candidate/README.md b/fixtures/react-parity/ag-ui-candidate/README.md index 43de337c5..fd847f280 100644 --- a/fixtures/react-parity/ag-ui-candidate/README.md +++ b/fixtures/react-parity/ag-ui-candidate/README.md @@ -5,17 +5,21 @@ selector. A fixture entry directly forwards their real exports and types. The temporary `@threadplane/ag-ui` package keeps version 0.2.0 and does not change the published Angular package root or introduce a permanent migration API. -With the repository's existing dependencies available, build the prerequisites -and install the exact local service lock: +With the repository's existing npm dependencies, Python 3.12 and `uv` available, +build the prerequisites and install both exact local service locks: ```sh -NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,angular,react --skip-nx-cache --outputStyle=stream +NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,content,angular,react --skip-nx-cache --outputStyle=stream npm ci --prefix deployments/ag-ui-mastra --no-audit --no-fund +export THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT=/tmp/threadplane-agui-client-tools-native-fixture-env +UV_PYTHON=3.12 UV_PROJECT_ENVIRONMENT="$THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT" uv sync --frozen --no-install-project --project cockpit/ag-ui/client-tools/python +node --test scripts/react-parity/ag-ui-candidate-package.spec.mjs fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs +node scripts/react-parity/verify-ag-ui-client-tools.mjs node scripts/react-parity/verify-ag-ui-candidate.mjs ``` -The verifier emits 20 backend modules plus the direct forwarding entry, with -21 corresponding declarations. It packs into its own temporary directory and +The verifier emits the exact reviewed set of 22 backend modules plus the direct +forwarding entry, with 23 corresponding declarations. It packs into its own temporary directory and installs real core, React, Angular and backend tarballs. The existing APF output and framework-only SDK installation ban remain separate required checks. There is no output rewrite, source alias, narrowed declaration or backend wrapper. @@ -37,6 +41,34 @@ and disposal. Strict NodeNext and browser checks use the full installed declarations with `skipLibCheck: false`. Framework bindings retain the native snapshot type. Core is required by those declarations; its implementation need not occur in the backend's runtime graph. +The sole additional bare subpath is a type-only `@threadplane/core/tools` +declaration dependency resolved from the actual packed core tools entry. +The candidate retains exactly two runtime exports, `createSession` and +`projectTextTranscript`; its client-tool session, snapshot, call and blocker +types forward directly from the native runtime modules. Heterogeneous authored +handler argument/result contracts are verified under NodeNext and Bundler, +including readonly projections, an empty catalog and the original no-catalog +snapshot contract. + +The installed client-tools proof uses `createSession(options, catalog)` against +an owned loopback server and the actual canonical compiled Python graph, +repository middleware and locked adapter. Only the model is replaced with an +offline scripted model. In separate nonstream and stream worker lifetimes, one +human submission causes two distinct physical runs, one handler invocation, +an exact authored result carried into the real model, a final answer and an +exact native result echo. Snapshot-only calls and streamed argument repair are +verified from genuine adapter events. Unknown calls, held-terminal cancellation +and noncooperative handler cancellation are separate negative scenarios. Each +receipt records sources, module identities, exact Python patch and package +versions, denied socket/DNS operations and owned resource closure. + +The Library CI job uses its existing Python 3.12 setup and performs the frozen +client-tools sync before the scoped worker tests and full candidate proof. +`uv run --frozen --no-sync` deliberately relies on that completed sync. To verify +a clean environment locally, choose a new empty directory for +`THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT` and repeat the sync and checks above. +The fixture accepts any Python 3.12 patch and records the actual patch in its +provenance. It requires no provider credentials or paid model requests. The installed browser uses the same application and independent HTTP oracle as the original native review: all 13 controls, two owners, both frameworks, child @@ -69,8 +101,9 @@ build hashes and installed artifact hashes describe different stages: npm can omit build-only metadata such as `.npmignore`. Installed bytes are compared to the actual unpacked tarball, not to an assumed identical dist directory. -This does not establish hosted model access, universal provider support, durable -cancellation, persistence, history loading, reconnect, client-tool execution, SSR -or hydration. The source review and source Mastra executables remain separate +This establishes the specified local native client-tool execution and automatic +continuation with an offline scripted model. It does not establish hosted model +access, universal provider support, durable cancellation or execution, persistence, +history loading, reconnect, SSR or hydration. The source review and source Mastra executables remain separate required proofs; they alone do not prove candidate installation. Final public selector placement, backend root migration and releases remain separate work. diff --git a/fixtures/react-parity/ag-ui-candidate/backend-binding.ts b/fixtures/react-parity/ag-ui-candidate/backend-binding.ts index 4ebb4f200..ee2a4b0b0 100644 --- a/fixtures/react-parity/ag-ui-candidate/backend-binding.ts +++ b/fixtures/react-parity/ag-ui-candidate/backend-binding.ts @@ -2,5 +2,9 @@ export { createSession, projectTextTranscript } from '@threadplane/ag-ui'; export type { Session, SessionOptions, + ClientToolSession, + ClientToolSessionSnapshot, + ClientToolCall, + ClientToolBlocker, TextTranscriptRow, } from '@threadplane/ag-ui'; diff --git a/fixtures/react-parity/ag-ui-candidate/entry.ts b/fixtures/react-parity/ag-ui-candidate/entry.ts index 2abbb036f..90045006d 100644 --- a/fixtures/react-parity/ag-ui-candidate/entry.ts +++ b/fixtures/react-parity/ag-ui-candidate/entry.ts @@ -3,6 +3,12 @@ export { createSession } from '../../../libs/ag-ui/src/runtime/create-session.js export type { Session, SessionOptions, + ClientToolSession, } from '../../../libs/ag-ui/src/runtime/create-session.js'; +export type { + ClientToolSessionSnapshot, + ClientToolCall, + ClientToolBlocker, +} from '../../../libs/ag-ui/src/runtime/client-tools.js'; export { projectTextTranscript } from '../../../libs/ag-ui/src/runtime/text-transcript.js'; export type { TextTranscriptRow } from '../../../libs/ag-ui/src/runtime/text-transcript.js'; diff --git a/fixtures/react-parity/ag-ui-candidate/installed-types.ts b/fixtures/react-parity/ag-ui-candidate/installed-types.ts index a8dce0468..94791063f 100644 --- a/fixtures/react-parity/ag-ui-candidate/installed-types.ts +++ b/fixtures/react-parity/ag-ui-candidate/installed-types.ts @@ -4,7 +4,13 @@ import { type Session, type SessionOptions, type TextTranscriptRow, + type ClientToolSession, + type ClientToolSessionSnapshot, + type ClientToolCall, + type ClientToolBlocker, } from '@threadplane/ag-ui'; +import type { CheckedTools, ToolContracts } from '@threadplane/core/tools'; +import type { CompleteOutcome } from '@threadplane/core'; export type NativeResponse = Parameters[1][number]; export function checkCandidate(signal: AbortSignal) { const options: SessionOptions = { @@ -67,3 +73,68 @@ export function checkCandidate(signal: AbortSignal) { void session.addToolResult('id', 'result'); return { session, snapshot, rows, stable }; } +export function checkClientTools(options: SessionOptions) { + const catalog = { + get_weather: { + description: 'Weather', + handler: (args: { location: string; nested: { values: number[] } }) => ({ + temperatures: [args.location.length], + }), + }, + book: { + description: 'Book', + handler: async (args: { seats: number }) => ({ + confirmation: String(args.seats), + }), + }, + }; + const checked = catalog satisfies CheckedTools; + const owner: ClientToolSession> = createSession( + options, + checked + ); + const snapshot: ClientToolSessionSnapshot> = + owner.getSnapshot(); + const calls: readonly ClientToolCall>[] = + snapshot.clientTools.calls; + const outcome: CompleteOutcome | undefined = + snapshot.clientTools.commandOutcome; + const blocker: ClientToolBlocker | undefined = snapshot.clientTools.blocker; + void outcome; + void blocker; + for (const call of calls) { + const name: 'get_weather' | 'book' = call.name; + void name; + if (call.name === 'get_weather') { + const location: string | undefined = call.args?.location; + const temperatures: readonly number[] | undefined = + call.result?.temperatures; + void location; + void temperatures; + // @ts-expect-error Heterogeneous results stay narrowed. + call.result?.confirmation; + // @ts-expect-error Nested arguments are deeply readonly. + call.args?.nested.values.push(1); + // @ts-expect-error Nested results are deeply readonly. + call.result?.temperatures.push(1); + } else { + const seats: number | undefined = call.args?.seats; + const confirmation: string | undefined = call.result?.confirmation; + void seats; + void confirmation; + // @ts-expect-error Names determine authored argument contracts. + call.args?.location; + } + // @ts-expect-error Local evidence is readonly. + call.delivery = 'observed'; + } + const empty = createSession(options, {}); + const impossible: readonly never[] = empty.getSnapshot().clientTools.calls; + void impossible; + void empty.getSnapshot().clientTools.blocker; + // @ts-expect-error No catalog keeps the original exact snapshot. + createSession(options).getSnapshot().clientTools; + // @ts-expect-error Native snapshots do not manufacture core aggregates. + snapshot.messages; + return owner; +} diff --git a/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py b/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py new file mode 100644 index 000000000..2f95013a9 --- /dev/null +++ b/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py @@ -0,0 +1,127 @@ +"""Bounded offline oracle: actual canonical graph, middleware and locked adapter. + +Only the canonical module's _base_llm is replaced. This worker never generates +AG-UI events itself: every event is normalized from LangGraphAgent.run. +""" +import asyncio +import hashlib +import importlib +import importlib.metadata +import json +import os +from pathlib import Path +import socket +import sys +import traceback + +ROOT = Path(__file__).resolve().parents[3] +MAX_BYTES = 2_000_000 +MAX_REQUESTS = 12 +os.environ['OPENAI_API_KEY'] = 'local-scripted-model-unused' +os.environ['LANGSMITH_TRACING'] = 'false' +os.environ['LANGCHAIN_TRACING_V2'] = 'false' +for key in list(os.environ): + if key.endswith('_API_KEY') and key != 'OPENAI_API_KEY' or key in ['LANGSMITH_ENDPOINT', 'LANGCHAIN_ENDPOINT']: + os.environ.pop(key, None) +socket_attempts = [] +blocked_socket_constructions = [] + +def deny_socket(self, address, *args): + socket_attempts.append(str(address)) + raise RuntimeError('External sockets forbidden in offline canonical worker') + +socket.socket.connect = deny_socket +socket.socket.connect_ex = deny_socket +socket.socket.sendto = deny_socket +def audit_network(event, args): + if event == 'socket.__new__' and args[1] in [socket.AF_INET, socket.AF_INET6]: + blocked_socket_constructions.append({'event': event, 'family': int(args[1]), 'stack': [f'{frame.filename}:{frame.lineno}:{frame.name}' for frame in traceback.extract_stack(limit=8)]}) + raise RuntimeError('Internet socket construction forbidden in offline canonical worker') + if event in ['socket.connect', 'socket.getaddrinfo', 'socket.gethostbyname', 'socket.gethostbyaddr', 'socket.getnameinfo', 'socket.sendto', 'socket.sendmsg']: + socket_attempts.append(event) + raise RuntimeError('External socket operations and DNS forbidden in offline canonical worker') + +sys.addaudithook(audit_network) +sys.path.insert(0, str(ROOT / 'packages/threadplane-middleware/src')) +sys.path.insert(0, str(ROOT / 'cockpit/ag-ui/client-tools/python')) + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from pydantic import Field +from ag_ui.core import RunAgentInput +from ag_ui_langgraph import LangGraphAgent + +class ScriptedModel(BaseChatModel): + catalogs: list = Field(default_factory=list) + observed_messages: list = Field(default_factory=list) + call_name: str = 'get_weather' + + @property + def _llm_type(self): + return 'offline-canonical-client-tools-fixture' + + def bind_tools(self, tools, **kwargs): + self.catalogs.append(tools) + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + self.observed_messages.append([m.model_dump(mode='json') for m in messages]) + answer = AIMessage(id='scripted-final-answer', content='Scripted weather receipt received.') if any(isinstance(m, ToolMessage) for m in messages) else AIMessage(id='scripted-weather-call', content='', tool_calls=[{'id': 'scripted-weather-call-id', 'name': self.call_name, 'args': {'location': 'KASE'}, 'type': 'tool_call'}]) + return ChatResult(generations=[ChatGeneration(message=answer)]) + +class ScriptedStreamingModel(ScriptedModel): + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + self.observed_messages.append([m.model_dump(mode='json') for m in messages]) + if any(isinstance(m, ToolMessage) for m in messages): + yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-final-answer', content='Scripted weather receipt received.')) + else: + yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', tool_call_chunks=[{'id': 'scripted-weather-call-id', 'name': self.call_name, 'args': '{"location":', 'index': 0}])) + yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', tool_call_chunks=[{'id': None, 'name': None, 'args': '"KASE"}', 'index': 0}])) + yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', response_metadata={'finish_reason': 'tool_calls'})) + +def emit(value): + encoded = json.dumps(value, separators=(',', ':')) + if len(encoded.encode()) > MAX_BYTES: + raise RuntimeError('worker output bound exceeded') + print(encoded, flush=True) + +async def main(): + assert sys.version_info[:2] == (3, 12), 'Canonical fixture requires actual Python 3.12' + mode = sys.argv[1] if len(sys.argv) > 1 else 'nonstream' + assert mode in ['nonstream', 'stream', 'unknown'] + module = importlib.import_module('src.graph') + model = ScriptedStreamingModel() if mode == 'stream' else ScriptedModel(call_name='unknown_weather' if mode == 'unknown' else 'get_weather') + module._base_llm = model + graph = module.build_client_tools_graph() + agent = LangGraphAgent(name='offline-canonical-client-tools', graph=graph) + paths = [Path(__file__), ROOT / 'cockpit/ag-ui/client-tools/python/src/graph.py', ROOT / 'cockpit/ag-ui/client-tools/python/prompts/client-tools.md', ROOT / 'cockpit/ag-ui/client-tools/python/uv.lock', ROOT / 'cockpit/ag-ui/client-tools/python/pyproject.toml'] + modules = {name: str(Path(sys.modules[name].__file__).resolve()) for name in ['src.graph', 'ag_ui_langgraph.agent', 'threadplane.middleware.langgraph.middleware']} + paths.extend(Path(value) for value in modules.values()) + identity = {'pid': os.getpid(), 'python': sys.version.split()[0], 'mode': mode, 'modules': modules, 'socketAttempts': socket_attempts, 'blockedSocketConstructions': blocked_socket_constructions, 'versions': {name: importlib.metadata.version(name) for name in ['ag-ui-langgraph', 'ag-ui-protocol', 'langgraph', 'langchain-core']}, 'sources': [{'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in dict.fromkeys(paths)]} + emit({'ready': identity}) + count = 0 + while True: + line = await asyncio.to_thread(sys.stdin.buffer.readline, MAX_BYTES + 1) + if not line: + break + count += 1 + try: + if len(line) > MAX_BYTES or not line.endswith(b'\n') or count > MAX_REQUESTS: + raise RuntimeError('worker request bound exceeded') + input = RunAgentInput.model_validate(json.loads(line)) + async def collect(): + events = [] + async for event in agent.run(input): + if len(events) >= 256: + raise RuntimeError('worker event bound exceeded') + events.append(event.model_dump(mode='json', by_alias=True, exclude_none=True)) + return events + events = await asyncio.wait_for(collect(), timeout=15) + emit({'events': events, 'observations': {'requestCount': count, 'workerPid': os.getpid(), 'graphIdentity': id(graph), 'checkpointerIdentity': id(graph.checkpointer), 'modelIdentity': id(model), 'modelCatalogs': model.catalogs, 'modelMessages': model.observed_messages, 'socketAttempts': socket_attempts}}) + except Exception as error: + emit({'error': str(error), 'socketAttempts': socket_attempts}) + break + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/fixtures/react-parity/native-ag-ui-client-tools/installed-probe.mjs b/fixtures/react-parity/native-ag-ui-client-tools/installed-probe.mjs new file mode 100644 index 000000000..8e429a87c --- /dev/null +++ b/fixtures/react-parity/native-ag-ui-client-tools/installed-probe.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { createSession, projectTextTranscript } from '@threadplane/ag-ui'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +/** Actual installed consumer: one human submit owns both physical requests. */ +export async function runInstalledProbe({ url, scenario = 'positive' }) { + const originalFetch = globalThis.fetch; + const requests = [], + snapshots = [], + handlerCalls = []; + let session; + const catalog = { + get_weather: { + description: 'Scripted local weather', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + /** @param {{ location: string }} args */ + handler: (args) => { + handlerCalls.push(structuredClone(args)); + if (scenario === 'handler-cancel') { + queueMicrotask(() => session.stop()); + return new Promise(() => {}); + } + return { location: args.location, temperatureF: 68 }; + }, + }, + }; + globalThis.fetch = (input, init) => { + assert.equal( + String(input), + url + '/agent', + 'native owner uses its exact owned transport' + ); + requests.push(JSON.parse(init.body)); + return originalFetch(input, init); + }; + try { + const inert = createSession( + { threadId: randomUUID(), url: url + '/agent' }, + catalog + ); + const unsubscribe = inert.subscribe(() => {}); + unsubscribe(); + inert.dispose(); + assert.deepEqual( + requests, + [], + 'construction, observation and disposal are inert' + ); + const threadId = randomUUID(); + session = createSession({ threadId, url: url + '/agent' }, catalog); + session.subscribe(() => + snapshots.push(structuredClone(session.getSnapshot())) + ); + const done = session.submit('Scripted weather request'); + if (scenario === 'held-cancel') { + const deadline = Date.now() + 15000; + for (;;) { + const stats = await (await originalFetch(url + '/stats')).json(); + if (stats.requests[0]?.held) break; + assert.ok(Date.now() < deadline, 'bounded terminal hold'); + await new Promise((yes) => setTimeout(yes, 20)); + } + session.stop(); + } + const outcome = await done; + const snapshot = structuredClone(session.getSnapshot()); + if (scenario === 'positive') { + assert.equal( + requests.length, + 2, + 'automatic continuation, no second human submit' + ); + assert.equal(handlerCalls.length, 1); + assert.deepEqual(handlerCalls[0], { location: 'KASE' }); + for (const request of requests) { + assert.equal(request.threadId, threadId); + assert.match(request.runId, /^[0-9a-f-]{36}$/); + assert.equal( + request.messages.filter((row) => row.role === 'user').length, + 1 + ); + assert.deepEqual(request.tools, [ + { + name: 'get_weather', + description: catalog.get_weather.description, + parameters: catalog.get_weather.parameters, + }, + ]); + } + assert.notEqual(requests[0].runId, requests[1].runId); + assert.deepEqual(requests[0].messages[0], requests[1].messages[0]); + const result = requests[1].messages.find((row) => row.role === 'tool'); + assert.ok( + result, + 'locally authored tool result is carried by the actual next request' + ); + assert.equal(result.toolCallId, 'scripted-weather-call-id'); + assert.equal( + result.content, + JSON.stringify({ location: 'KASE', temperatureF: 68 }) + ); + const local = snapshot.clientTools.calls[0]; + assert.equal(local.name, 'get_weather'); + assert.equal(local.status, 'complete'); + assert.equal(local.sourceRunId, requests[0].runId); + assert.equal(local.provenance, 'local'); + assert.deepEqual(local.args, { location: 'KASE' }); + assert.deepEqual(local.result, { location: 'KASE', temperatureF: 68 }); + assert.ok(['observed', 'sent-unconfirmed'].includes(local.delivery)); + assert.equal(snapshot.clientTools.blocker, undefined); + assert.ok( + projectTextTranscript(snapshot.transcript).some( + (row) => row.content === 'Scripted weather receipt received.' + ) + ); + } else { + assert.equal(requests.length, 1); + assert.equal(handlerCalls.length, scenario === 'handler-cancel' ? 1 : 0); + assert.equal( + snapshot.clientTools.blocker?.reason, + scenario === 'unknown' + ? 'unsupported' + : scenario === 'handler-cancel' + ? 'cancelled' + : 'uncertain' + ); + } + session.dispose(); + return { + scenario, + requests, + handlerCalls, + snapshots, + snapshot, + outcome, + installedEntry: fileURLToPath(import.meta.resolve('@threadplane/ag-ui')), + inertFetches: 0, + }; + } finally { + session?.dispose(); + globalThis.fetch = originalFetch; + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const result = await runInstalledProbe(JSON.parse(process.argv[2])); + process.stdout.write(JSON.stringify(result) + '\n'); +} diff --git a/fixtures/react-parity/native-ag-ui-client-tools/server.mjs b/fixtures/react-parity/native-ag-ui-client-tools/server.mjs new file mode 100644 index 000000000..686830191 --- /dev/null +++ b/fixtures/react-parity/native-ag-ui-client-tools/server.mjs @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { connect } from 'node:net'; +import { readFileSync, realpathSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +const defaultRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../..' +); +const MAX_BYTES = 2_000_000; +const sha = (path) => + createHash('sha256').update(readFileSync(path)).digest('hex'); +function alive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} +export function readProcessInventory(run = execFileSync) { + const output = run('ps', ['-axo', 'pid=,ppid=,pgid=,lstart=,args='], { + encoding: 'utf8', + timeout: 1000, + maxBuffer: 4_000_000, + }); + return output + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const match = line + .trim() + .match( + /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.+)$/ + ); + assert.ok( + match, + 'Malformed process inventory; ownership remains unverified' + ); + const [, pid, ppid, pgid, birth, command] = match; + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + birth: birth.replace(/\s+/g, ' '), + command, + }; + }); +} +export function ownedGroupMembers(inventory, pgid, owners) { + const members = inventory.filter((row) => row.pgid === pgid); + for (const member of members) { + const owner = owners.get(member.pid); + assert.ok( + owner && member.birth === owner.birth && member.command === owner.command, + 'Ambiguous or unrelated group member; termination forbidden' + ); + } + return members; +} +async function portAbsent(port) { + return new Promise((yes) => { + const socket = connect({ port, host: '127.0.0.1' }); + socket.setTimeout(1000, () => { + socket.destroy(); + yes(false); + }); + socket.once('connect', () => { + socket.destroy(); + yes(false); + }); + socket.once('error', (error) => { + socket.destroy(); + yes(error.code === 'ECONNREFUSED'); + }); + }); +} +export async function createClientToolsServer({ + root = defaultRoot, + mode = 'nonstream', + holdTerminal = false, + environment = process.env.THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT ?? + '/tmp/threadplane-agui-client-tools-native-fixture-env', +} = {}) { + root = realpathSync(root); + assert.ok(['nonstream', 'stream', 'unknown'].includes(mode)); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !/(?:API_KEY|TOKEN|SECRET|LANGSMITH|LANGCHAIN)/i.test(key) + ) + ); + Object.assign(env, { + UV_PROJECT_ENVIRONMENT: environment, + UV_PYTHON: '3.12', + LANGSMITH_TRACING: 'false', + LANGCHAIN_TRACING_V2: 'false', + }); + const worker = join( + root, + 'fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py' + ); + const child = spawn( + 'uv', + [ + 'run', + '--frozen', + '--no-sync', + '--project', + 'cockpit/ag-ui/client-tools/python', + 'python', + worker, + mode, + ], + { cwd: root, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true } + ); + const records = [], + errors = [], + sockets = new Set(), + responses = new Set(), + waiting = []; + let ready, + buffer = '', + stderr = '', + exited = false, + closing, + port, + active = false; + const ownedProcesses = new Map(); + const closure = { + childAbsent: false, + processGroupAbsent: false, + portAbsent: false, + responsesClosed: false, + socketsClosed: false, + stdinClosed: false, + }; + const stats = () => + structuredClone({ ready, requests: records, errors, stderr, closure }); + const fail = (error) => { + errors.push(String(error)); + for (const pending of waiting.splice(0)) pending.reject(error); + }; + child.stderr.on('data', (chunk) => { + stderr += chunk; + if (stderr.length > MAX_BYTES) { + stderr = stderr.slice(0, MAX_BYTES); + fail(new Error('worker stderr bound')); + } + }); + child.on('error', fail); + child.stdin.on('error', fail); + child.on('exit', (code, signal) => { + exited = true; + if (!closing) fail(new Error(`worker exited ${code}/${signal}`)); + }); + child.stdout.on('data', (chunk) => { + buffer += chunk; + if (buffer.length > MAX_BYTES) { + fail(new Error('worker output bound')); + child.kill('SIGTERM'); + return; + } + while (buffer.includes('\n')) { + const at = buffer.indexOf('\n'), + line = buffer.slice(0, at); + buffer = buffer.slice(at + 1); + try { + const value = JSON.parse(line); + const pending = waiting.shift(); + assert.ok(pending, 'unsolicited worker output'); + pending.resolve(value); + } catch (error) { + fail(error); + } + } + }); + const receive = () => { + const result = new Promise((resolve, reject) => + waiting.push({ resolve, reject }) + ); + let timer; + return Promise.race([ + result, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('worker response timeout')), + 20000 + ); + }), + ]).finally(() => clearTimeout(timer)); + }; + const server = createServer(async (request, response) => { + request.setTimeout(15000, () => request.destroy()); + response.setTimeout(20000, () => response.destroy()); + responses.add(response); + response.once('close', () => responses.delete(response)); + const json = (status, value) => + response + .writeHead(status, { 'content-type': 'application/json' }) + .end(JSON.stringify(value)); + try { + if (request.method === 'GET' && request.url === '/stats') + return json(200, stats()); + if (request.method === 'POST' && request.url === '/release') { + for (const record of records) + if (record.held && !record.closed) { + record.response.write( + `data: ${JSON.stringify(record.events.at(-1))}\n\n` + ); + record.response.end(); + record.held = false; + } + return json(200, { released: true }); + } + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/agent'); + assert.ok( + !active && records.length < 12 && !exited, + 'bounded sequential requests' + ); + active = true; + const record = { closed: false, events: [], held: false }; + records.push(record); + // The response itself is non-enumerable: stats retain only independent evidence. + Object.defineProperty(record, 'response', { value: response }); + response.once('close', () => { + record.closed = true; + }); + let body = ''; + for await (const chunk of request) { + body += chunk; + assert.ok(body.length <= MAX_BYTES, 'HTTP input bound'); + } + record.body = JSON.parse(body); + const pending = receive(); + child.stdin.write(body + '\n'); + const output = await pending; + assert.ok(!output.error, output.error); + assert.ok(Array.isArray(output.events) && output.events.length <= 256); + record.events = output.events; + record.observations = output.observations; + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + }); + record.held = holdTerminal; + const events = holdTerminal ? output.events.slice(0, -1) : output.events; + for (const event of events) + if (!response.destroyed) + response.write(`data: ${JSON.stringify(event)}\n\n`); + if (!holdTerminal) response.end(); + } catch (error) { + errors.push(String(error)); + if (!response.headersSent) json(422, { error: String(error) }); + else response.destroy(); + } finally { + if (request.url === '/agent') active = false; + } + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + async function close() { + return (closing ??= (async () => { + for (const response of responses) response.destroy(); + for (const socket of sockets) socket.destroy(); + if (server.listening) await new Promise((yes) => server.close(yes)); + child.stdin.end(); + closure.stdinClosed = true; + // macOS can return EPERM for signal-0 against a vanished process group. + // Inspect exact PGID membership independently before any owned-group signal. + const groupAlive = () => + Number.isInteger(child.pid) && + readProcessInventory().some((row) => row.pgid === child.pid); + const deadline = Date.now() + 1500; + while (groupAlive() && Date.now() < deadline) await delay(20); + if (groupAlive()) { + ownedGroupMembers(readProcessInventory(), child.pid, ownedProcesses); + try { + process.kill(-child.pid, 'SIGTERM'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + const killDeadline = Date.now() + 1500; + while (groupAlive() && Date.now() < killDeadline) await delay(20); + if (groupAlive()) { + ownedGroupMembers(readProcessInventory(), child.pid, ownedProcesses); + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + const finalDeadline = Date.now() + 1500; + while ( + ((child.pid && !exited) || (ready && alive(ready.pid))) && + Date.now() < finalDeadline + ) + await delay(20); + closure.childAbsent = + (!child.pid || (exited && !alive(child.pid))) && + (!ready || !alive(ready.pid)); + closure.processGroupAbsent = !groupAlive(); + closure.portAbsent = port ? await portAbsent(port) : true; + closure.responsesClosed = responses.size === 0; + closure.socketsClosed = sockets.size === 0; + assert.ok( + Object.values(closure).every(Boolean), + 'All exact owned fixture resources closed' + ); + })()); + } + try { + const initial = await receive(); + ready = initial.ready; + assert.ok(ready && Number.isInteger(ready.pid)); + if (ready.pid !== child.pid) + assert.equal( + Number( + execFileSync('ps', ['-o', 'ppid=', '-p', String(ready.pid)], { + encoding: 'utf8', + }).trim() + ), + child.pid, + 'ready worker is the exact spawned child descendant' + ); + assert.equal( + Number( + execFileSync('ps', ['-o', 'pgid=', '-p', String(ready.pid)], { + encoding: 'utf8', + }).trim() + ), + child.pid, + 'worker belongs to only its owned process group' + ); + const inventory = readProcessInventory(); + for (const pid of new Set([child.pid, ready.pid])) { + const identity = inventory.find((row) => row.pid === pid); + assert.ok( + identity && + identity.pgid === child.pid && + identity.command.includes(worker), + 'Exact launched fixture source and process birth required' + ); + ownedProcesses.set(pid, identity); + } + ownedGroupMembers(inventory, child.pid, ownedProcesses); + assert.equal(ready.mode, mode); + assert.match(ready.python, /^3\.12\./); + assert.equal(ready.versions['ag-ui-langgraph'], '0.0.45'); + assert.equal(ready.versions['ag-ui-protocol'], '1.0.0'); + assert.equal(ready.versions.langgraph, '1.2.4'); + assert.equal( + ready.modules['src.graph'], + realpathSync(join(root, 'cockpit/ag-ui/client-tools/python/src/graph.py')) + ); + assert.ok( + ready.modules['threadplane.middleware.langgraph.middleware'].startsWith( + join(root, 'packages/threadplane-middleware/src/') + ) + ); + for (const source of ready.sources) + assert.equal(source.sha256, sha(source.path)); + await new Promise((yes, no) => { + server.once('error', no); + server.listen(0, '127.0.0.1', yes); + }); + port = server.address().port; + return { url: `http://127.0.0.1:${port}`, pid: ready.pid, stats, close }; + } catch (error) { + await close(); + error.fixtureClosure = stats(); + throw error; + } +} diff --git a/fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs b/fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs new file mode 100644 index 000000000..c2fe33314 --- /dev/null +++ b/fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + createClientToolsServer, + readProcessInventory, + ownedGroupMembers, +} from './server.mjs'; + +test( + 'actual locked worker denies every CPython DNS resolver audit event before localhost-only resolution', + { timeout: 25000 }, + () => { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + const worker = fileURLToPath( + new URL('./compiled_worker.py', import.meta.url) + ); + const source = `import json, runpy, socket, sys +worker = runpy.run_path(sys.argv[1]) +results = [] +for name, event, invoke in [ + ('getaddrinfo', 'socket.getaddrinfo', lambda: socket.getaddrinfo('localhost', 0)), + ('gethostbyname', 'socket.gethostbyname', lambda: socket.gethostbyname('localhost')), + ('gethostbyname_ex', 'socket.gethostbyname', lambda: socket.gethostbyname_ex('localhost')), + ('gethostbyaddr', 'socket.gethostbyaddr', lambda: socket.gethostbyaddr('127.0.0.1')), + ('getnameinfo', 'socket.getnameinfo', lambda: socket.getnameinfo(('127.0.0.1', 0), socket.NI_NUMERICHOST | socket.NI_NUMERICSERV)), +]: + baseline = len(worker['socket_attempts']) + try: + result = invoke() + results.append({'name': name, 'event': event, 'blocked': False, 'result': result, 'recorded': worker['socket_attempts'][baseline:]}) + except Exception as error: + results.append({'name': name, 'event': event, 'blocked': isinstance(error, RuntimeError), 'error': str(error), 'recorded': worker['socket_attempts'][baseline:]}) +print(json.dumps({'python': sys.version.split()[0], 'results': results})) +`; + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !/(?:API_KEY|TOKEN|SECRET|LANGSMITH|LANGCHAIN)/i.test(key) + ) + ); + Object.assign(env, { + UV_PROJECT_ENVIRONMENT: + process.env.THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT ?? + '/tmp/threadplane-agui-client-tools-native-fixture-env', + UV_PYTHON: '3.12', + LANGSMITH_TRACING: 'false', + LANGCHAIN_TRACING_V2: 'false', + }); + const result = JSON.parse( + execFileSync( + 'uv', + [ + 'run', + '--frozen', + '--no-sync', + '--project', + 'cockpit/ag-ui/client-tools/python', + 'python', + '-c', + source, + worker, + ], + { + cwd: root, + env, + encoding: 'utf8', + timeout: 20000, + maxBuffer: 1_000_000, + } + ) + ); + assert.match(result.python, /^3\.12\./); + assert.equal(result.results.length, 5); + for (const outcome of result.results) { + assert.equal(outcome.blocked, true, JSON.stringify(outcome)); + assert.equal( + outcome.error, + 'External socket operations and DNS forbidden in offline canonical worker' + ); + assert.deepEqual(outcome.recorded, [outcome.event]); + } + } +); + +test('process inventory is bounded and rejects failed or malformed ps output', () => { + assert.throws( + () => + readProcessInventory(() => { + throw new Error('ps failed'); + }), + /ps failed/ + ); + assert.throws(() => readProcessInventory(() => 'malformed'), /Malformed/); + const rows = readProcessInventory((command, args, options) => { + assert.equal(command, 'ps'); + assert.equal(options.timeout, 1000); + assert.equal(options.maxBuffer, 4_000_000); + return '100 99 100 Mon Oct 12 18:00:00 2026 python compiled_worker.py\n'; + }); + assert.equal(rows[0].pid, 100); +}); +test('independent empty group inventory resolves an ambiguous signal-0 probe without signalling', () => { + assert.deepEqual(ownedGroupMembers([], 100, new Map()), []); +}); +test('group ownership rejects PID reuse and unrelated members before signalling', () => { + const owner = { + pid: 100, + ppid: 99, + pgid: 100, + birth: 'original', + command: 'python compiled_worker.py', + }; + const owners = new Map([[100, owner]]); + assert.deepEqual(ownedGroupMembers([owner], 100, owners), [owner]); + assert.throws( + () => ownedGroupMembers([{ ...owner, birth: 'reused' }], 100, owners), + /termination forbidden/ + ); + assert.throws( + () => ownedGroupMembers([{ ...owner, command: 'unrelated' }], 100, owners), + /termination forbidden/ + ); + assert.throws( + () => ownedGroupMembers([owner, { ...owner, pid: 101 }], 100, owners), + /termination forbidden/ + ); +}); + +test( + 'owned real compiled worker reports canonical identity and closes its process and port', + { timeout: 30000 }, + async () => { + const server = await createClientToolsServer(); + try { + const stats = await (await fetch(server.url + '/stats')).json(); + assert.equal(stats.ready.pid, server.pid); + assert.match(stats.ready.python, /^3\.12\./); + assert.match( + stats.ready.modules['src.graph'], + /cockpit\/ag-ui\/client-tools\/python\/src\/graph.py$/ + ); + assert.match( + stats.ready.modules['threadplane.middleware.langgraph.middleware'], + /packages\/threadplane-middleware\/src/ + ); + assert.equal(stats.ready.versions['ag-ui-langgraph'], '0.0.45'); + assert.deepEqual(stats.requests, []); + } finally { + await server.close(); + } + assert.equal(server.stats().closure.childAbsent, true); + assert.equal(server.stats().closure.portAbsent, true); + } +); +test( + 'invalid worker request fails closed without leaving owned resources', + { timeout: 30000 }, + async () => { + const server = await createClientToolsServer(); + try { + const response = await fetch(server.url + '/agent', { + method: 'POST', + body: JSON.stringify({ invalid: true }), + }); + assert.equal(response.status, 422); + assert.ok(server.stats().errors.length); + } finally { + await server.close(); + } + assert.equal(server.stats().closure.childAbsent, true); + assert.equal(server.stats().closure.portAbsent, true); + } +); diff --git a/libs/ag-ui/src/runtime/client-tool-ledger.spec.ts b/libs/ag-ui/src/runtime/client-tool-ledger.spec.ts new file mode 100644 index 000000000..452789862 --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tool-ledger.spec.ts @@ -0,0 +1,921 @@ +import { describe, expect, it } from 'vitest'; +import type { AGUIEvent, Message } from '@ag-ui/client'; +import { createClientToolLedger } from './client-tool-ledger.js'; +import { ownTranscript } from './transcript.js'; + +type Contracts = { + weather: { args: { city: string }; result: { degrees: number } }; +}; +const call = ( + id = 'c', + raw = '{"city":"Paris"}', + name = 'weather', + owner = 'a', + child?: string +): Message => ({ + id: owner, + role: 'assistant', + ...(child && { subagentRunId: child }), + toolCalls: [{ id, type: 'function', function: { name, arguments: raw } }], +}); +const tool = (id = 'result', toolCallId = 'c', content = 'ok'): Message => ({ + id, + role: 'tool', + toolCallId, + content, +}); +const event = (value: unknown) => value as AGUIEvent; +const success = (pending?: string[]) => + event({ + type: 'RUN_FINISHED', + runId: 'r', + threadId: 't', + ...(pending !== undefined && { + outcome: { type: 'success', pendingToolCallIds: pending }, + }), + }); +function fixture(initial: Message[] = []) { + const ledger = createClientToolLedger( + ['weather'], + ownTranscript(initial) + ); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: ownTranscript(initial), + }); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + ownTranscript(initial) + ); + const finish = (messages: Message[], pending?: string[]) => { + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages }), + ownTranscript(messages) + ); + ledger.observe(physical, success(pending), ownTranscript(messages)); + return ledger.admitSuccess(physical); + }; + return { ledger, physical, finish }; +} + +describe('request-local client tool ledger', () => { + it.each(['not-paused', 'foreign-pause', 'rewritten', 'new'] as const)( + 'denies native resume capture for %s history', + (mode) => { + const { ledger, physical } = fixture(); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: [call()] }), + ownTranscript([call()]) + ); + if (mode !== 'not-paused') + ledger.observe( + physical, + event({ + type: 'RUN_FINISHED', + threadId: mode === 'foreign-pause' ? 'foreign' : 't', + runId: 'r', + outcome: { type: 'interrupt', interrupts: [{ id: 'approve' }] }, + }), + ownTranscript([call()]) + ); + const history = + mode === 'rewritten' + ? [call('c', '{"city":"Rome"}')] + : mode === 'new' + ? [call(), call('new', '{}', 'weather', 'new-owner')] + : [call()]; + ledger.startPhysical({ + runId: 'resume', + threadId: 't', + transcript: ownTranscript(history), + }); + expect(() => + ledger.captureNativeResumeRequest(ownTranscript(history), physical) + ).toThrow(); + } + ); + it('captures the actual retained result batch for the exact paused request without acknowledgement', () => { + const { ledger, physical, finish } = fixture(); + finish([call()]); + ledger.markRunning('c'); + ledger.complete('c', 'local', { degrees: 20 }, ownTranscript([call()])); + const history = ledger.retainHistory(ownTranscript([call()])); + const continuation = ledger.startPhysical({ + runId: 'continuation', + threadId: 't', + transcript: history, + }); + ledger.observe( + continuation, + event({ type: 'RUN_STARTED', threadId: 't', runId: 'continuation' }), + history + ); + ledger.observe( + continuation, + event({ + type: 'RUN_FINISHED', + threadId: 't', + runId: 'continuation', + outcome: { type: 'interrupt', interrupts: [{ id: 'approve' }] }, + }), + history + ); + ledger.startPhysical({ + runId: 'resume', + threadId: 't', + transcript: history, + }); + expect(() => + ledger.captureNativeResumeRequest(history, physical) + ).toThrow(); + const capture = ledger.captureNativeResumeRequest(history, continuation); + expect(capture.transcript).toEqual(history); + expect(capture.resultMessageIds).toEqual(['local']); + expect(ledger.snapshot().calls[0].delivery).toBe('unsent'); + ledger.markFetchInvoked(capture); + expect(ledger.snapshot().calls[0].delivery).toBe('sent-unconfirmed'); + }); + it('admits snapshot-only finalized root calls as an immutable typed group', () => { + const { ledger, finish } = fixture(); + expect(finish([call()])).toEqual(['c']); + expect(ledger.snapshot().calls[0]).toMatchObject({ + name: 'weather', + args: { city: 'Paris' }, + status: 'pending', + provenance: 'local', + delivery: 'unsent', + }); + expect(Object.isFrozen(ledger.snapshot().calls[0].args)).toBe(true); + }); + it('uses final snapshot arguments to repair provisional fragments', () => { + const { ledger, physical, finish } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_START', + toolCallId: 'c', + toolCallName: 'weather', + parentMessageId: 'a', + }), + ownTranscript([call('c', '')]) + ); + ledger.observe( + physical, + event({ type: 'TOOL_CALL_ARGS', toolCallId: 'c', delta: '{"city":' }), + ownTranscript([call('c', '{"city":')]) + ); + expect(finish([call('c', ' { "city": "Paris" } ')])).toEqual(['c']); + expect(ledger.snapshot().calls[0].args).toEqual({ city: 'Paris' }); + }); + it('requires tool end and valid JSON for a stream without a final snapshot', () => { + for (const ended of [false, true]) { + const { ledger, physical } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_START', + toolCallId: 'c', + toolCallName: 'weather', + parentMessageId: 'a', + }), + ownTranscript([call()]) + ); + if (ended) + ledger.observe( + physical, + event({ type: 'TOOL_CALL_END', toolCallId: 'c' }), + ownTranscript([call()]) + ); + ledger.observe(physical, success(), ownTranscript([call()])); + expect(ledger.admitSuccess(physical)).toEqual(ended ? ['c'] : []); + } + const { ledger, physical } = fixture(); + ledger.observe( + physical, + event({ type: 'TOOL_CALL_END', toolCallId: 'c' }), + ownTranscript([call('c', '{')]) + ); + ledger.observe(physical, success(), ownTranscript([call('c', '{')])); + expect(ledger.admitSuccess(physical)).toEqual([]); + }); + it.each([ + undefined, + { type: 'RUN_FINISHED', threadId: 't', runId: 'foreign' }, + { type: 'RUN_FINISHED', threadId: 't', runId: 'r', subagentRunId: 'child' }, + { + type: 'RUN_FINISHED', + threadId: 't', + runId: 'r', + outcome: { type: 'interrupt', interrupts: [] }, + }, + { type: 'RUN_ERROR', message: 'error' }, + ])('admits nothing without matching genuine success: %j', (terminal) => { + const { ledger, physical } = fixture(); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: [call()] }), + ownTranscript([call()]) + ); + if (terminal) + ledger.observe(physical, event(terminal), ownTranscript([call()])); + expect(ledger.admitSuccess(physical)).toEqual([]); + expect(ledger.snapshot().calls).toEqual([]); + }); + it('does not admit child calls, backend-result calls or removed calls', () => { + expect( + fixture().finish([call('c', '{}', 'weather', 'a', 'child')]) + ).toEqual([]); + expect(fixture().finish([call(), tool()])).toEqual([]); + const { ledger, physical, finish } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_START', + toolCallId: 'c', + toolCallName: 'weather', + parentMessageId: 'a', + }), + ownTranscript([call()]) + ); + expect(finish([])).toEqual([]); + }); + it.each([[[]], [['missing']], [['c', 'c']]])( + 'blocks invalid or orphan explicit selection %j', + (pending) => { + const { ledger, finish } = fixture(); + expect(finish([call()], pending)).toEqual([]); + expect(ledger.snapshot().blocker).toBeDefined(); + expect(ledger.snapshot().calls).toEqual([]); + } + ); + it('blocks the whole group for unknown names and excluded unresolved calls', () => { + for (const pending of [undefined, ['c']]) { + const { ledger, finish } = fixture(); + expect( + finish([call(), call('other', '{}', 'unknown', 'b')], pending) + ).toEqual([]); + expect(ledger.snapshot().blocker).toBeDefined(); + expect(ledger.snapshot().calls).toEqual([]); + } + }); + it('blocks imported or resumed baseline unresolved root history', () => { + expect(fixture([call()]).ledger.snapshot().blocker?.reason).toBe( + 'unsupported' + ); + const { ledger } = fixture(); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: ownTranscript([call()]), + }); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + ownTranscript([call()]) + ); + ledger.observe(physical, success(['c']), ownTranscript([call()])); + expect(ledger.admitSuccess(physical)).toEqual([]); + expect(ledger.snapshot().blocker).toBeDefined(); + }); + it.each([ + call('c', '{}', 'weather', 'other'), + call('c', '{}', 'unknown'), + call('c', '{}', 'weather', 'a', 'child'), + ])('blocks a candidate ownership/name/attribution rewrite', (rewritten) => { + const { ledger, physical, finish } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_START', + toolCallId: 'c', + toolCallName: 'weather', + parentMessageId: 'a', + }), + ownTranscript([call()]) + ); + expect(finish([rewritten])).toEqual([]); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('consumes all calls in final order before publication and does not replay', () => { + const { ledger, finish } = fixture(); + expect(finish([call('second', '{}', 'weather', 'b'), call()])).toEqual([ + 'second', + 'c', + ]); + expect(ledger.snapshot().calls.map((item) => item.status)).toEqual([ + 'pending', + 'pending', + ]); + ledger.complete( + 'second', + 's', + { degrees: 2 }, + ownTranscript([call('second', '{}', 'weather', 'b'), call()]) + ); + ledger.complete( + 'c', + 'z', + { degrees: 3 }, + ownTranscript([call('second', '{}', 'weather', 'b'), call()]) + ); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: ownTranscript([]), + }); + const history = ownTranscript([ + call('second', ' {} ', 'weather', 'b'), + call('c', ' {"city":"Paris"} '), + ]); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + history + ); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: history }), + history + ); + ledger.observe(physical, success(), history); + expect(ledger.admitSuccess(physical)).toEqual([]); + expect(ledger.snapshot().blocker).toBeUndefined(); + expect(ledger.snapshot().calls).toHaveLength(2); + }); + it('blocks a consumed invocation rewrite even when a backend result exists', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + expect( + ledger.retainHistory( + ownTranscript([ + call('c', '{"city":"Rome"}'), + tool('result', 'c', '{"degrees":1}'), + ]) + ) + ).toHaveLength(2); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('keeps malformed final JSON raw and produces a legitimate local error', () => { + const { ledger, finish } = fixture(); + expect(finish([call('c', '{')])).toEqual(['c']); + expect(ledger.snapshot().calls[0]).toMatchObject({ + rawArguments: '{', + error: expect.any(String), + status: 'pending', + }); + expect(ledger.snapshot().calls[0]).not.toHaveProperty('args'); + ledger.fail( + 'c', + 'error-result', + 'Invalid JSON arguments', + ownTranscript([call('c', '{')]) + ); + expect( + ledger.retainHistory(ownTranscript([call('c', '{')]))[1] + ).toMatchObject({ + role: 'tool', + content: 'Error: Invalid JSON arguments', + toolCallId: 'c', + }); + }); + it('owns copied results and mutable handler args independently including dangerous keys', () => { + const { ledger, finish } = fixture(); + finish([ + call('c', '{"city":"Paris","__proto__":{"x":1},"constructor":{"y":2}}'), + ]); + const args = ledger.argumentsFor('c') as { city: string }; + args.city = 'Rome'; + expect(ledger.snapshot().calls[0].args).toMatchObject({ + city: 'Paris', + __proto__: { x: 1 }, + }); + const result = { degrees: 1 }; + ledger.markRunning('c'); + ledger.complete( + 'c', + 'result', + result, + ownTranscript([ + call('c', '{"city":"Paris","__proto__":{"x":1},"constructor":{"y":2}}'), + ]) + ); + result.degrees = 99; + expect(ledger.snapshot().calls[0].result).toEqual({ degrees: 1 }); + expect(Object.isFrozen(ledger.snapshot().calls[0].result)).toBe(true); + }); + it.each([ + ['literal', 'literal'], + [undefined, ''], + [{ degrees: 4 }, '{"degrees":4}'], + ])('encodes result %j explicitly', (value, content) => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', value, ownTranscript([call()])); + expect(ledger.retainHistory(ownTranscript([call()]))[1]).toMatchObject({ + content, + }); + }); + it.each([ + () => 1, + new Date(), + Number.NaN, + { x: undefined }, + { toJSON: () => 'hidden' }, + ])( + 'retains explicit error for nonportable or lossy encoded results', + (value) => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', value, ownTranscript([call()])); + expect(ledger.snapshot().calls[0].status).toBe('error'); + expect(ledger.retainHistory(ownTranscript([call()]))[1]).toMatchObject({ + content: expect.stringMatching(/^Error:/), + }); + } + ); + it('rejects result message collisions before publication', () => { + const { ledger, finish } = fixture(); + finish([call()]); + expect(() => + ledger.complete('c', 'a', { degrees: 1 }, ownTranscript([call()])) + ).toThrow(); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + expect(ledger.snapshot().calls[0].status).toBe('pending'); + }); + it('captures exact request batches and marks only invoked captured results', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const captured = ledger.captureRequest(ownTranscript([call()])); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: captured.transcript, + }); + const next = ownTranscript([call(), call('d', '{}', 'weather', 'b')]); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + captured.transcript + ); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: next }), + next + ); + ledger.observe(physical, success(), next); + expect(ledger.admitSuccess(physical)).toEqual(['d']); + ledger.complete( + 'd', + 'later', + { degrees: 2 }, + ownTranscript([call(), call('d', '{}', 'weather', 'b')]) + ); + expect(ledger.snapshot().calls.map((item) => item.delivery)).toEqual([ + 'unsent', + 'unsent', + ]); + ledger.markFetchInvoked(captured); + expect(ledger.snapshot().calls.map((item) => item.delivery)).toEqual([ + 'sent-unconfirmed', + 'unsent', + ]); + expect(captured.transcript).toHaveLength(2); + }); + it('retains omitted sent results and acknowledges only exact native echoes', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + ledger.markFetchInvoked(ledger.captureRequest(ownTranscript([call()]))); + expect(ledger.retainHistory(ownTranscript([call()]))).toHaveLength(2); + expect(ledger.snapshot().calls[0].delivery).toBe('sent-unconfirmed'); + ledger.reconcileNativeHistory( + ownTranscript([call(), tool('result', 'c', '{"degrees":1}')]) + ); + expect(ledger.snapshot().calls[0].delivery).toBe('observed'); + }); + it.each([ + tool('result', 'c', 'changed'), + tool('result', 'other', '{"degrees":1}'), + tool('alternate', 'c', '{"degrees":1}'), + ])('blocks incompatible native echo %j', (echo) => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + ledger.reconcileNativeHistory(ownTranscript([call(), echo])); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('does not invent messages for cancelled incomplete calls or allow orphan history', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.cancelIncomplete('r'); + expect(ledger.snapshot().calls[0].status).toBe('cancelled'); + expect(ledger.retainHistory(ownTranscript([call()]))).toHaveLength(1); + expect(ledger.snapshot().blocker?.reason).toBe('cancelled'); + expect(ledger.historyIntegrity(ownTranscript([call()]))).toBe(false); + expect(() => + ledger.complete('c', 'late', { degrees: 1 }, ownTranscript([call()])) + ).toThrow(); + }); + it('does not acknowledge a retained local result while preparing another request', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const history = ledger.retainHistory(ownTranscript([call()])); + ledger.captureRequest(history); + expect(ledger.snapshot().calls[0].delivery).toBe('unsent'); + }); + it('copies result getters exactly once and rejects nested symbol data', () => { + const { ledger, finish } = fixture(); + finish([call()]); + let reads = 0; + ledger.complete( + 'c', + 'result', + { + get degrees() { + reads++; + return reads; + }, + }, + ownTranscript([call()]) + ); + expect(reads).toBe(1); + expect(ledger.snapshot().calls[0].result).toEqual({ degrees: 1 }); + const other = fixture(); + other.finish([call()]); + other.ledger.complete( + 'c', + 'result', + { nested: { [Symbol('private')]: 1 } }, + ownTranscript([call()]) + ); + expect(other.ledger.snapshot().calls[0].status).toBe('error'); + }); + it('never admits a call whose backend result was observed then removed', () => { + const { ledger, physical, finish } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_RESULT', + messageId: 'backend', + toolCallId: 'c', + content: 'ok', + }), + ownTranscript([call(), tool('backend')]) + ); + expect(finish([call()])).toEqual([]); + expect(ledger.snapshot().blocker?.reason).toBe('unsupported'); + }); + it('ignores stale physical authority after a newer physical request starts', () => { + const { ledger, physical, finish } = fixture(); + ledger.startPhysical({ + runId: 'new', + threadId: 't', + transcript: ownTranscript([]), + }); + expect(finish([call()])).toEqual([]); + expect(ledger.snapshot().calls).toEqual([]); + expect(ledger.admitSuccess(physical)).toEqual([]); + }); + it('blocks an owner snapshot that removes a consumed invocation', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + ledger.reconcileNativeHistory( + ownTranscript([{ id: 'a', role: 'assistant', content: 'replaced' }]) + ); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('retains the exact local invocation and result when native history omits both', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const history = ledger.reconcileNativeHistory(ownTranscript([])); + expect(history).toHaveLength(2); + expect(ledger.historyIntegrity(history)).toBe(true); + expect(ledger.snapshot().calls[0].delivery).toBe('unsent'); + }); + it('blocks incompatible child event attribution even if the transcript is root', () => { + const { ledger, physical, finish } = fixture(); + ledger.observe( + physical, + event({ + type: 'TOOL_CALL_START', + toolCallId: 'c', + toolCallName: 'weather', + parentMessageId: 'a', + subagentRunId: 'child', + }), + ownTranscript([call()]) + ); + expect(finish([call()])).toEqual([]); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('does not accept forged result batches', () => { + const { ledger } = fixture(); + expect(() => + ledger.markFetchInvoked({ + transcript: ownTranscript([]), + resultMessageIds: [], + }) + ).toThrow(); + }); + it('blocks native local-result conflicts before admitting another group', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const baseline = ledger.retainHistory(ownTranscript([call()])); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: baseline, + }); + const next = ownTranscript([ + call(), + tool('result', 'c', 'changed'), + call('d', '{}', 'weather', 'b'), + ]); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + baseline + ); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: next }), + next + ); + ledger.observe(physical, success(), next); + expect(ledger.admitSuccess(physical)).toEqual([]); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + expect(ledger.snapshot().calls).toHaveLength(1); + }); + it('refuses request capture with admitted incomplete calls', () => { + const { ledger, finish } = fixture(); + finish([call()]); + expect(() => ledger.captureRequest(ownTranscript([call()]))).toThrow(); + }); + it('does not publish a result if a result getter cancels its admitted call', () => { + const { ledger, finish } = fixture(); + finish([call()]); + expect(() => + ledger.complete( + 'c', + 'result', + { + get degrees() { + ledger.cancelIncomplete('r'); + return 1; + }, + }, + ownTranscript([call()]) + ) + ).toThrow(); + expect(ledger.snapshot().calls[0].status).toBe('cancelled'); + expect(ledger.retainHistory(ownTranscript([call()]))).toHaveLength(1); + }); + it('captures the first command blocker without retaining caller mutations', () => { + const { ledger } = fixture(); + const callIds = ['c']; + ledger.installBlocker({ reason: 'uncertain', sourceRunId: 'r', callIds }); + callIds.push('other'); + ledger.installBlocker({ + reason: 'limit', + sourceRunId: 'later', + callIds: [], + }); + expect(ledger.snapshot().blocker).toEqual({ + reason: 'uncertain', + sourceRunId: 'r', + callIds: ['c'], + }); + }); + it.each([undefined, null, 1, ['c', 1]])( + 'blocks an explicitly present incompatible pending declaration %j', + (pendingToolCallIds) => { + const { ledger, physical } = fixture(); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: [call()] }), + ownTranscript([call()]) + ); + ledger.observe( + physical, + event({ + type: 'RUN_FINISHED', + threadId: 't', + runId: 'r', + outcome: { type: 'success', pendingToolCallIds }, + }), + ownTranscript([call()]) + ); + expect(ledger.admitSuccess(physical)).toEqual([]); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + } + ); + it('does not conflate a decoded overflow number invocation with null', () => { + const { ledger, finish } = fixture(); + finish([call('c', '{"city":1e400}')]); + ledger.complete( + 'c', + 'result', + { degrees: 1 }, + ownTranscript([call('c', '{"city":1e400}')]) + ); + ledger.retainHistory(ownTranscript([call('c', '{"city":null}')])); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + }); + it('keeps an omitted local owner and result before a later native answer and next human', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + ledger.markFetchInvoked(ledger.captureRequest(ownTranscript([call()]))); + const answer: Message = { + id: 'answer', + role: 'assistant', + content: 'done', + }; + const human: Message = { id: 'next-human', role: 'user', content: 'next' }; + expect( + ledger + .reconcileNativeHistory(ownTranscript([answer])) + .map((message) => message.id) + ).toEqual(['a', 'result', 'answer']); + expect( + ledger + .retainHistory(ownTranscript([answer, human])) + .map((message) => message.id) + ).toEqual(['a', 'result', 'answer', 'next-human']); + expect( + ledger + .captureRequest(ownTranscript([answer, human])) + .transcript.map((message) => message.id) + ).toEqual(['a', 'result', 'answer', 'next-human']); + expect(ledger.snapshot().calls[0].delivery).toBe('sent-unconfirmed'); + expect(ledger.snapshot().blocker).toBeUndefined(); + }); + it('retains multiple group owners and results between surviving earlier and later history anchors', () => { + const earlier: Message = { + id: 'earlier-human', + role: 'user', + content: 'first', + }; + const middle: Message = { + id: 'middle-answer', + role: 'assistant', + content: 'first answer', + }; + const later: Message = { + id: 'last-answer', + role: 'assistant', + content: 'second answer', + }; + const nextHuman: Message = { + id: 'next-human', + role: 'user', + content: 'next', + }; + const { ledger, finish } = fixture([earlier]); + finish([earlier, call()]); + ledger.complete( + 'c', + 'first-result', + { degrees: 1 }, + ownTranscript([earlier, call()]) + ); + const baseline = ledger.retainHistory( + ownTranscript([earlier, call(), middle]) + ); + const physical = ledger.startPhysical({ + runId: 'r', + threadId: 't', + transcript: baseline, + }); + const second = ownTranscript([ + ...baseline, + call('d', '{}', 'weather', 'b'), + ]); + ledger.observe( + physical, + event({ type: 'RUN_STARTED', runId: 'r', threadId: 't' }), + baseline + ); + ledger.observe( + physical, + event({ type: 'MESSAGES_SNAPSHOT', messages: second }), + second + ); + ledger.observe(physical, success(), second); + expect(ledger.admitSuccess(physical)).toEqual(['d']); + ledger.complete('d', 'second-result', { degrees: 2 }, second); + const expected = [ + 'earlier-human', + 'a', + 'first-result', + 'middle-answer', + 'b', + 'second-result', + 'last-answer', + ]; + expect( + ledger + .reconcileNativeHistory(ownTranscript([earlier, middle, later])) + .map((message) => message.id) + ).toEqual(expected); + expect( + ledger + .retainHistory(ownTranscript([earlier, middle, later, nextHuman])) + .map((message) => message.id) + ).toEqual([...expected, 'next-human']); + expect( + ledger + .reconcileNativeHistory(ownTranscript([later])) + .map((message) => message.id) + ).toEqual(['a', 'first-result', 'b', 'second-result', 'last-answer']); + expect( + ledger + .reconcileNativeHistory(ownTranscript([])) + .map((message) => message.id) + ).toEqual(['a', 'first-result', 'b', 'second-result']); + expect( + ledger + .retainHistory( + ownTranscript([{ id: 'fresh-human', role: 'user', content: 'fresh' }]) + ) + .map((message) => message.id) + ).toEqual(['a', 'first-result', 'b', 'second-result', 'fresh-human']); + expect(ledger.snapshot().blocker).toBeUndefined(); + }); + it('captures history position independently of caller mutation and preserves owner rewrite conflicts', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const answer: Message = { + id: 'answer', + role: 'assistant', + content: 'done', + }; + const input: Message[] = [answer]; + const retained = ledger.reconcileNativeHistory(input); + input.push({ id: 'injected-human', role: 'user', content: 'injected' }); + answer.id = 'changed'; + expect(retained.map((message) => message.id)).toEqual([ + 'a', + 'result', + 'answer', + ]); + expect( + ledger + .retainHistory( + ownTranscript([{ id: 'answer', role: 'assistant', content: 'done' }]) + ) + .map((message) => message.id) + ).toEqual(['a', 'result', 'answer']); + ledger.reconcileNativeHistory( + ownTranscript([ + call('c', '{"city":"Rome"}'), + { id: 'answer', role: 'assistant', content: 'done' }, + ]) + ); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + expect(ledger.snapshot().calls[0].args).toEqual({ city: 'Paris' }); + }); + it('rejects authoring a local result under an omitted known native message identity', () => { + const prior: Message = { + id: 'prior-native', + role: 'user', + content: 'first', + }; + const { ledger, finish } = fixture([prior]); + finish([call()]); + expect(() => + ledger.complete( + 'c', + 'prior-native', + { degrees: 1 }, + ownTranscript([call()]) + ) + ).toThrow(); + expect(ledger.snapshot().blocker?.reason).toBe('conflict'); + expect(ledger.snapshot().calls[0].status).toBe('pending'); + }); + it('preserves current native row order while restoring only legitimate local rows', () => { + const { ledger, finish } = fixture(); + finish([call()]); + ledger.complete('c', 'result', { degrees: 1 }, ownTranscript([call()])); + const left: Message = { id: 'left', role: 'assistant', content: 'left' }; + const right: Message = { id: 'right', role: 'assistant', content: 'right' }; + ledger.reconcileNativeHistory(ownTranscript([left, right])); + expect( + ledger + .retainHistory(ownTranscript([right, left])) + .map((message) => message.id) + ).toEqual(['a', 'result', 'right', 'left']); + }); +}); diff --git a/libs/ag-ui/src/runtime/client-tool-ledger.ts b/libs/ag-ui/src/runtime/client-tool-ledger.ts new file mode 100644 index 000000000..4784a6c6e --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tool-ledger.ts @@ -0,0 +1,801 @@ +import type { AGUIEvent } from '@ag-ui/client'; +import type { ToolContract } from '@threadplane/core'; +import { copyData } from '../lib/internal/copy-data.js'; +import type { ClientToolBlocker, ClientToolCall } from './client-tools.js'; +import { assertTranscriptIdentity } from './session-observation.js'; +import { ownTranscript, type Transcript } from './transcript.js'; + +type Assistant = Extract; +type ToolMessage = Extract; +interface Invocation { + id: string; + owner: Assistant; + name: string; + raw: string; + child?: string; + args?: unknown; + error?: string; + identity: string; +} +interface Physical { + runId: string; + threadId: string; + baseline: Set; + baselineTranscript: Transcript; + transcript: Transcript; + seen: Map; + finalized: Set; + ended: Set; + backendResults: Set; + started: boolean; + success: boolean; + paused: boolean; + pendingDeclared: boolean; + pending?: unknown; + processed: boolean; +} +export interface ClientToolRequestCapture { + readonly transcript: Transcript; + readonly resultMessageIds: readonly string[]; +} + +/** JSON invocation equality ignores formatting and object key order, never raw + * malformed input. Entry arrays avoid treating __proto__/constructor as code. */ +function canonical(value: unknown): string { + if ( + typeof value === 'number' && + (!Number.isFinite(value) || Object.is(value, -0)) + ) + return `number:${Object.is(value, -0) ? '-0' : String(value)}`; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonical( + (value as Record)[key] + )}` + ) + .join(',')}}`; +} +function invocations(transcript: Transcript): Invocation[] { + assertTranscriptIdentity(transcript); + const result: Invocation[] = []; + for (const owner of transcript) { + if (owner.role !== 'assistant') continue; + for (const call of owner.toolCalls ?? []) { + const raw = call.function.arguments; + let args: unknown; + let error: string | undefined; + try { + args = copyData(JSON.parse(raw), true); + } catch { + error = 'Invalid JSON arguments'; + } + result.push({ + id: call.id, + owner, + name: call.function.name, + raw, + ...(typeof owner.subagentRunId === 'string' && { + child: owner.subagentRunId, + }), + ...(error ? { error } : { args }), + identity: error ? `raw:${raw}` : `json:${canonical(args)}`, + }); + } + } + return result; +} +function sameOwner(a: Invocation, b: Invocation): boolean { + return a.owner.id === b.owner.id && a.name === b.name && a.child === b.child; +} +function sameInvocation(a: Invocation, b: Invocation): boolean { + return sameOwner(a, b) && a.identity === b.identity; +} +function backendResult(transcript: Transcript, call: Invocation): boolean { + return transcript.some( + (message) => + message.role === 'tool' && + message.toolCallId === call.id && + (typeof message.subagentRunId === 'string' + ? message.subagentRunId + : undefined) === call.child + ); +} +/** Result wire encoding must not silently discard undefined fields, symbols, + * array holes/extra keys, non-finite numbers or custom serialization. */ +function assertEncodable(value: unknown, top = true): void { + if (value === undefined && top) return; + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (typeof value !== 'object' || value === null) + throw new TypeError('Result cannot be encoded losslessly'); + if (Object.getOwnPropertySymbols(value).length) + throw new TypeError('Result contains symbol keys'); + if (Array.isArray(value)) { + if (Object.keys(value).length !== value.length) + throw new TypeError('Result contains array holes or extra keys'); + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) + throw new TypeError('Result contains array holes'); + assertEncodable(value[index], false); + } + } else + for (const key of Object.keys(value)) + assertEncodable((value as Record)[key], false); +} + +/** Own result data in one traversal, rejecting symbols at every depth before + * they could be discarded. copyData's plain-data/prototype policy is retained. */ +function ownResult(value: unknown, ancestors = new Set()): unknown { + if (value === null || typeof value !== 'object') return copyData(value, true); + if (Object.getOwnPropertySymbols(value).length) + throw new TypeError('Result contains symbol keys'); + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ( + array + ? prototype !== Array.prototype + : prototype !== Object.prototype && prototype !== null + ) + throw new TypeError('Result must use plain objects or arrays'); + if (ancestors.has(value)) throw new TypeError('Result must be acyclic'); + ancestors.add(value); + try { + const copied = array ? new Array(value.length) : Object.create(prototype); + for (const key of Object.keys(value)) + Object.defineProperty(copied, key, { + value: ownResult((value as Record)[key], ancestors), + enumerable: true, + }); + return Object.freeze(copied); + } finally { + ancestors.delete(value); + } +} + +/** Session-lifetime facts only. The command owner executes handlers, decides + * cancellation/continuation and invokes the fetch guard; this module does not. */ +export function createClientToolLedger>( + names: readonly (keyof C & string)[], + initial: Transcript +) { + const catalog = new Set(names); + const attempts = new WeakMap(); + const captures = new WeakMap< + ClientToolRequestCapture, + readonly ToolMessage[] + >(); + const consumed = new Map(); + const calls = new Map>(); + const results = new Map(); + // Keep only observed placement facts for omitted messages. Their native + // content is not retained or reconstructed; only legitimate local owners and + // results survive omission. This preserves their position between anchors. + const ownedOrder: string[] = []; + const ownedRoles = new Map(); + const rememberOrder = (history: Transcript) => { + for (let index = 0; index < history.length; index++) { + const message = history[index]; + if (ownedRoles.has(message.id)) continue; + const following = history + .slice(index + 1) + .find((candidate) => ownedRoles.has(candidate.id)); + const insertion = following + ? ownedOrder.indexOf(following.id) + : ownedOrder.length; + ownedOrder.splice(insertion, 0, message.id); + ownedRoles.set(message.id, message.role); + } + }; + rememberOrder(ownTranscript(initial)); + let blocker: ClientToolBlocker | undefined; + let latestRunId = ''; + let activePhysical: object | undefined; + const block = ( + reason: ClientToolBlocker['reason'], + callIds: readonly string[], + sourceRunId = latestRunId + ) => { + blocker ??= Object.freeze({ + reason, + sourceRunId, + callIds: Object.freeze([...callIds]), + }); + }; + const physicalFor = (token: object) => { + const physical = attempts.get(token); + if (!physical) throw new TypeError('Unknown physical request'); + return physical; + }; + const change = (id: string, fields: Partial>) => { + const previous = calls.get(id); + if (!previous) throw new TypeError('Call has not been admitted'); + calls.set( + id, + Object.freeze({ ...previous, ...fields }) as ClientToolCall + ); + }; + const invocationFor = (id: string): Invocation => { + const invocation = consumed.get(id); + if (!invocation) throw new TypeError('Call has not been admitted'); + return invocation; + }; + const validateConsumed = (history: Transcript) => { + for (const invocation of invocations(history)) { + const previous = consumed.get(invocation.id); + if (previous && !sameInvocation(previous, invocation)) + block('conflict', [invocation.id]); + } + }; + const integrity = ( + history: Transcript, + selected: readonly string[] = [], + sourceRunId = latestRunId + ) => { + validateConsumed(history); + const unresolved = invocations(history).filter( + (call) => + !call.child && + !backendResult(history, call) && + !( + results.has(call.id) && sameInvocation(invocationFor(call.id), call) + ) && + !selected.includes(call.id) + ); + if (unresolved.length) + block( + 'unsupported', + unresolved.map((call) => call.id), + sourceRunId + ); + return !blocker; + }; + const observeEchoes = (history: Transcript) => { + for (const [id, result] of results) { + for (const message of history) { + if ( + message.id === result.id || + (message.role === 'tool' && message.toolCallId === id) + ) { + if ( + message.role !== 'tool' || + message.id !== result.id || + message.toolCallId !== id || + message.content !== result.content || + typeof message.subagentRunId === 'string' + ) + block('conflict', [id]); + else + change(id, { delivery: 'observed' } as Partial>); + } + } + } + }; + const overlayHistory = ( + nativeHistory: Transcript, + nativeEcho: boolean + ): Transcript => { + const history = ownTranscript(nativeHistory); + rememberOrder(history); + validateConsumed(history); + // Conflicts are checked even for request preparation; acknowledgement only + // comes from explicitly native evidence. + if (nativeEcho) observeEchoes(history); + else + for (const [id, result] of results) + for (const message of history) { + if ( + (message.id === result.id || + (message.role === 'tool' && message.toolCallId === id)) && + (message.role !== 'tool' || + message.id !== result.id || + message.toolCallId !== id || + message.content !== result.content || + typeof message.subagentRunId === 'string') + ) + block('conflict', [id]); + } + const protectedMessages = new Map(); + for (const [id, result] of results) { + const invocation = invocationFor(id); + // Preserve the legitimate result's owning assistant if an authoritative + // native snapshot omitted it. Never manufacture an owner for pending calls. + const currentOwner = history.find( + (message) => message.id === invocation.owner.id + ); + if (currentOwner && !invocations(history).some((call) => call.id === id)) + block('conflict', [id]); + protectedMessages.set( + invocation.owner.id, + currentOwner ?? invocation.owner + ); + protectedMessages.set(result.id, result); + } + // Restore protected rows before their surviving later anchors. With no + // anchors, their prior owned order still precedes newly observed rows. + // Keep the current native rows in their supplied order. + const retained = history.filter( + (message) => !protectedMessages.has(message.id) + ); + const positions = new Map(ownedOrder.map((id, index) => [id, index])); + for (const id of ownedOrder) { + const protectedMessage = protectedMessages.get(id); + if (!protectedMessage) continue; + const position = positions.get(id) ?? ownedOrder.length; + const insertion = retained.findIndex( + (message) => (positions.get(message.id) ?? ownedOrder.length) > position + ); + retained.splice( + insertion === -1 ? retained.length : insertion, + 0, + protectedMessage + ); + } + return ownTranscript(retained); + }; + const finish = ( + id: string, + messageId: string, + history: Transcript, + value: unknown, + failure?: string, + completionAuthority?: () => boolean + ) => { + if (completionAuthority?.() === false) + throw new TypeError('Call lost command completion authority'); + const invocation = consumed.get(id); + const projection = calls.get(id); + if ( + !invocation || + !projection || + !['pending', 'running'].includes(projection.status) || + blocker + ) + throw new TypeError('Call has no completion authority'); + validateConsumed(history); + if (blocker) + throw new TypeError('Invocation conflicts with retained history'); + if ( + ownedRoles.has(messageId) || + history.some((message) => message.id === messageId) || + [...results.values()].some((message) => message.id === messageId) + ) { + block('conflict', [id]); + throw new TypeError('Local result message ID collision'); + } + let captured: unknown; + let error = failure ?? invocation.error; + let content: string; + try { + // Copy before encoding: custom getter results are read once, and neither + // JSON encoding nor a caller can mutate the retained typed value. + captured = ownResult(value); + assertEncodable(captured); + content = + typeof captured === 'string' + ? captured + : captured === undefined + ? '' + : JSON.stringify(captured); + } catch { + error ??= 'Client tool result is not portable or encodable'; + content = ''; + } + if (error !== undefined) content = `Error: ${error}`; + // Getter evaluation can reenter the command owner. Completion must not + // overwrite a cancellation, blocker or another legitimate completion. + if ( + completionAuthority?.() === false || + blocker || + calls.get(id) !== projection + ) + throw new TypeError( + 'Call lost completion authority while copying result' + ); + const message = Object.freeze({ + id: messageId, + role: 'tool' as const, + toolCallId: id, + content, + }); + results.set(id, message); + // An authored tool message belongs directly after its assistant's existing + // result rows, even if a later native snapshot has already omitted them. + rememberOrder(ownTranscript(history)); + let insertion = ownedOrder.indexOf(invocation.owner.id) + 1; + while ( + insertion < ownedOrder.length && + ownedRoles.get(ownedOrder[insertion]) === 'tool' + ) + insertion++; + ownedOrder.splice(insertion, 0, messageId); + ownedRoles.set(messageId, 'tool'); + change( + id, + error !== undefined + ? ({ status: 'error', error, delivery: 'unsent' } as Partial< + ClientToolCall + >) + : ({ + status: 'complete', + result: captured, + delivery: 'unsent', + } as Partial>) + ); + return message; + }; + integrity(ownTranscript(initial), [], ''); + return { + startPhysical(input: { + runId: string; + threadId: string; + transcript: Transcript; + }): object { + const transcript = ownTranscript(input.transcript); + const token = Object.freeze({}); + activePhysical = token; + latestRunId = input.runId; + attempts.set(token, { + runId: input.runId, + threadId: input.threadId, + transcript, + baselineTranscript: transcript, + baseline: new Set(invocations(transcript).map((call) => call.id)), + seen: new Map(), + finalized: new Set(), + ended: new Set(), + backendResults: new Set(), + started: false, + success: false, + paused: false, + pendingDeclared: false, + processed: false, + }); + return token; + }, + /** Call after the existing observation owner has applied a normalized event. */ + observe(token: object, event: AGUIEvent, observed: Transcript): void { + const physical = physicalFor(token); + if (physical.processed || activePhysical !== token) return; + physical.transcript = ownTranscript(observed); + const child = + 'subagentRunId' in event && typeof event.subagentRunId === 'string'; + if (event.type === 'RUN_STARTED') { + physical.started = + !child && + event.runId === physical.runId && + event.threadId === physical.threadId; + } + if (event.type === 'RUN_FINISHED') { + physical.paused = + physical.started && + !child && + event.runId === physical.runId && + event.threadId === physical.threadId && + event.outcome?.type === 'interrupt'; + physical.success = + physical.started && + !child && + event.runId === physical.runId && + event.threadId === physical.threadId && + (event.outcome == null || event.outcome.type === 'success'); + if ( + physical.success && + event.outcome?.type === 'success' && + Object.hasOwn(event.outcome, 'pendingToolCallIds') + ) { + physical.pendingDeclared = true; + physical.pending = copyData(event.outcome.pendingToolCallIds, true); + } + } else if (event.type === 'RUN_ERROR') physical.success = false; + if (event.type === 'TOOL_CALL_END' && !child) + physical.ended.add(event.toolCallId); + for (const message of physical.transcript) + if ( + message.role === 'tool' && + typeof message.subagentRunId !== 'string' + ) + physical.backendResults.add(message.toolCallId); + if ( + event.type === 'TOOL_CALL_START' || + event.type === 'TOOL_CALL_ARGS' || + event.type === 'TOOL_CALL_END' + ) { + const target = invocations(physical.transcript).find( + (call) => call.id === event.toolCallId + ); + const tag = + 'subagentRunId' in event && typeof event.subagentRunId === 'string' + ? event.subagentRunId + : undefined; + if ( + target && + (target.child !== tag || + (event.type === 'TOOL_CALL_START' && + (target.name !== event.toolCallName || + (typeof event.parentMessageId === 'string' && + target.owner.id !== event.parentMessageId)))) + ) + block('conflict', [event.toolCallId], physical.runId); + } + for (const invocation of invocations(physical.transcript)) { + if (physical.baseline.has(invocation.id)) continue; + const previous = physical.seen.get(invocation.id); + if (previous && !sameOwner(previous, invocation)) + block('conflict', [invocation.id], physical.runId); + else if (!previous) physical.seen.set(invocation.id, invocation); + } + if (event.type === 'MESSAGES_SNAPSHOT') { + physical.finalized = new Set( + invocations(ownTranscript(event.messages)).map((call) => call.id) + ); + observeEchoes(ownTranscript(event.messages)); + } else if (event.type === 'TOOL_CALL_RESULT') { + observeEchoes( + ownTranscript( + physical.transcript.filter( + (message) => message.id === event.messageId + ) + ) + ); + } + physical.transcript = overlayHistory(physical.transcript, false); + }, + admitSuccess(token: object, allowGroup = true): readonly string[] { + const physical = physicalFor(token); + if ( + physical.processed || + activePhysical !== token || + !physical.success || + blocker + ) + return Object.freeze([]); + physical.processed = true; + validateConsumed(physical.transcript); + const current = invocations(physical.transcript); + const eligible = current.filter( + (call) => + !call.child && + !physical.baseline.has(call.id) && + physical.seen.has(call.id) && + (physical.finalized.has(call.id) || + (physical.ended.has(call.id) && !call.error)) && + !backendResult(physical.transcript, call) && + !physical.backendResults.has(call.id) && + !consumed.has(call.id) + ); + let selected = eligible; + if (physical.pendingDeclared) { + const ids = physical.pending; + if ( + !Array.isArray(ids) || + new Set(ids).size !== ids.length || + ids.some( + (id) => + typeof id !== 'string' || + (!eligible.some((call) => call.id === id) && + !( + consumed.has(id) && + results.has(id) && + current.some( + (call) => + call.id === id && sameInvocation(invocationFor(id), call) + ) + )) + ) + ) { + block( + 'conflict', + Array.isArray(ids) + ? ids.filter((id): id is string => typeof id === 'string') + : [], + physical.runId + ); + return Object.freeze([]); + } + selected = eligible.filter((call) => ids.includes(call.id)); + } + const unknown = selected.filter((call) => !catalog.has(call.name)); + if (unknown.length) + block( + 'unsupported', + unknown.map((call) => call.id), + physical.runId + ); + if ( + !integrity( + physical.transcript, + selected.map((call) => call.id), + physical.runId + ) + ) + return Object.freeze([]); + if (selected.length && !allowGroup) { + block( + 'limit', + selected.map((call) => call.id), + physical.runId + ); + return Object.freeze([]); + } + for (const call of selected) { + consumed.set(call.id, call); + calls.set( + call.id, + Object.freeze({ + name: call.name, + callId: call.id, + assistantMessageId: call.owner.id, + sourceRunId: physical.runId, + rawArguments: call.raw, + ...(call.error ? { error: call.error } : { args: call.args }), + status: 'pending', + delivery: 'unsent', + provenance: 'local', + }) as ClientToolCall + ); + } + return Object.freeze(selected.map((call) => call.id)); + }, + snapshot(): { + readonly calls: readonly ClientToolCall[]; + readonly blocker?: ClientToolBlocker; + } { + return Object.freeze({ + calls: Object.freeze([...calls.values()]), + ...(blocker && { blocker }), + }); + }, + /** The command owner supplies uncertainty/limit facts; first blocker wins. */ + installBlocker(fact: ClientToolBlocker): void { + block(fact.reason, fact.callIds, fact.sourceRunId); + }, + argumentsFor(id: string): unknown { + const call = consumed.get(id); + if (!call || call.error) throw new TypeError('No decoded arguments'); + return copyData(call.args, false); + }, + markRunning(id: string): void { + if ( + blocker || + calls.get(id)?.status !== 'pending' || + consumed.get(id)?.error + ) + throw new TypeError('Call cannot run'); + change(id, { status: 'running' } as Partial>); + }, + complete( + id: string, + messageId: string, + value: unknown, + history: Transcript, + completionAuthority?: () => boolean + ): ToolMessage { + return finish( + id, + messageId, + history, + value, + undefined, + completionAuthority + ); + }, + fail( + id: string, + messageId: string, + error: string, + history: Transcript, + completionAuthority?: () => boolean + ): ToolMessage { + return finish( + id, + messageId, + history, + undefined, + error, + completionAuthority + ); + }, + cancelIncomplete(sourceRunId: string): void { + const incomplete = [...calls.values()].filter( + (call) => call.status === 'pending' || call.status === 'running' + ); + for (const call of incomplete) + change(call.callId, { status: 'cancelled' } as Partial< + ClientToolCall + >); + if (incomplete.length) + block( + 'cancelled', + incomplete.map((call) => call.callId), + sourceRunId + ); + }, + /** Overlay local facts; carrying one's own result is not a native echo. */ + retainHistory(history: Transcript): Transcript { + return overlayHistory(history, false); + }, + /** Only pass native messages from normalized snapshot/result observations. */ + reconcileNativeHistory(history: Transcript): Transcript { + return overlayHistory(history, true); + }, + historyIntegrity(history: Transcript): boolean { + return integrity(overlayHistory(history, false)); + }, + captureRequest(history: Transcript): ClientToolRequestCapture { + const transcript = overlayHistory(history, false); + if (!integrity(transcript)) + throw new TypeError('Request history is blocked'); + const batch = Object.freeze( + [...results.values()].filter((result) => + transcript.some((message) => message.id === result.id) + ) + ); + const capture = Object.freeze({ + transcript, + resultMessageIds: Object.freeze(batch.map((result) => result.id)), + }); + captures.set(capture, batch); + return capture; + }, + /** The owner must correlate and claim the native decision first. Only the + * exact unresolved invocations observed at that physical pause may travel + * as baseline; this grants no admission or handler authority. */ + captureNativeResumeRequest( + history: Transcript, + pauseToken: object + ): ClientToolRequestCapture { + const paused = physicalFor(pauseToken); + const transcript = overlayHistory(history, false); + if (!paused.paused || blocker) + throw new TypeError('No owned native pause'); + const prior = invocations(paused.transcript); + const unresolved = invocations(transcript).filter( + (call) => + !call.child && + !backendResult(transcript, call) && + !results.has(call.id) + ); + if ( + unresolved.some( + (call) => + !prior.some( + (previous) => + previous.id === call.id && sameInvocation(previous, call) + ) + ) + ) { + block( + 'conflict', + unresolved.map((call) => call.id), + paused.runId + ); + throw new TypeError('Resume history differs from owned pause'); + } + const batch = Object.freeze( + [...results.values()].filter((result) => + transcript.some((message) => message.id === result.id) + ) + ); + const capture = Object.freeze({ + transcript, + resultMessageIds: Object.freeze(batch.map((result) => result.id)), + }); + captures.set(capture, batch); + return capture; + }, + markFetchInvoked(capture: ClientToolRequestCapture): void { + const batch = captures.get(capture); + if (!batch) throw new TypeError('Unknown request capture'); + for (const message of batch) + if (calls.get(message.toolCallId)?.delivery !== 'observed') + change(message.toolCallId, { + delivery: 'sent-unconfirmed', + } as Partial>); + }, + }; +} diff --git a/libs/ag-ui/src/runtime/client-tools-session-http.spec.ts b/libs/ag-ui/src/runtime/client-tools-session-http.spec.ts new file mode 100644 index 000000000..5aef450cc --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tools-session-http.spec.ts @@ -0,0 +1,430 @@ +import type { RunAgentInput } from '@ag-ui/client'; +import { once } from 'node:events'; +import { createServer, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; + +async function bounded(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('HTTP tool milestone timeout')), + 2000 + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} +interface Exchange { + body: RunAgentInput; + response: ServerResponse; + send(...events: unknown[]): void; +} +async function serve() { + const exchanges: Exchange[] = []; + const arrivals = new Map void>(); + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.flushHeaders(); + const exchange = { + body: JSON.parse(Buffer.concat(chunks).toString()) as RunAgentInput, + response, + send: (...events: unknown[]) => { + for (const event of events) + response.write(`data: ${JSON.stringify(event)}\n\n`); + }, + }; + exchanges.push(exchange); + arrivals.get(exchanges.length - 1)?.(exchange); + }); + server.listen(0, '127.0.0.1'); + await bounded(once(server, 'listening')); + return { + exchanges, + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/tools`, + next: (index = 0) => + exchanges[index] + ? Promise.resolve(exchanges[index]) + : bounded( + new Promise((resolve) => arrivals.set(index, resolve)) + ), + close: async () => { + const closed = once(server, 'close'); + server.close(); + server.closeAllConnections(); + await bounded(closed); + }, + }; +} +function lifecycle(exchange: Exchange, type = 'RUN_STARTED') { + return { type, threadId: exchange.body.threadId, runId: exchange.body.runId }; +} +function assistant(index: number, raw = '{"value":1}') { + return { + id: `a${index}`, + role: 'assistant', + toolCalls: [ + { + id: `c${index}`, + type: 'function', + function: { name: 'f', arguments: raw }, + }, + ], + }; +} +const definition = ( + handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + 1 + ) +) => ({ + f: { + description: 'native tool', + parameters: { type: 'object' as const }, + handler, + }, +}); + +describe('native client tools over physical HTTP', () => { + it.each(['content', 'message-id'] as const)( + 'blocks a conflicting native %s echo without substituting the local result', + async (mode) => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const done = owner.submit('one'); + try { + const first = await server.next(); + first.send( + lifecycle(first), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.body.messages, assistant(0)], + }, + lifecycle(first, 'RUN_FINISHED') + ); + const next = await server.next(1); + const result = next.body.messages.find((m) => m.role === 'tool')!; + const changed = next.body.messages.map((m) => + m.id === result.id + ? { + ...m, + ...(mode === 'content' + ? { content: 'different' } + : { id: 'other-result' }), + } + : m + ); + next.send( + lifecycle(next), + { type: 'MESSAGES_SNAPSHOT', messages: changed }, + lifecycle(next, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('interrupted'); + expect(handler).toHaveBeenCalledOnce(); + expect(server.exchanges).toHaveLength(2); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe( + 'conflict' + ); + expect(owner.getSnapshot().clientTools.calls[0].delivery).toBe( + 'sent-unconfirmed' + ); + expect( + owner.getSnapshot().transcript.find((m) => m.id === result.id) + ).toEqual(result); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + } finally { + await owner.dispose(); + await done; + await server.close(); + } + } + ); + it.each(['result', 'removed'] as const)( + 'allows resumed baseline when backend %s resolves its orphan', + async (mode) => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const firstDone = owner.submit('one'); + try { + const first = await server.next(); + first.send( + lifecycle(first), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.body.messages, assistant(0)], + }, + { + ...lifecycle(first, 'RUN_FINISHED'), + outcome: { + type: 'interrupt', + interrupts: [{ id: 'approve', reason: 'confirm' }], + }, + } + ); + expect(await bounded(firstDone)).toBe('paused'); + const paused = owner.getSnapshot(); + if (paused.decision?.kind !== 'native') + throw new Error('Missing pause'); + const done = owner.resume(paused.decision.id, [ + { interruptId: 'approve', status: 'resolved', payload: true }, + ]); + const resumed = await server.next(1); + resumed.send( + lifecycle(resumed), + { + type: 'MESSAGES_SNAPSHOT', + messages: + mode === 'removed' + ? first.body.messages + : [ + ...resumed.body.messages, + { + id: 'backend-result', + role: 'tool', + toolCallId: 'c0', + content: 'resolved', + }, + ], + }, + lifecycle(resumed, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + expect(handler).not.toHaveBeenCalled(); + expect(owner.getSnapshot().clientTools.blocker).toBeUndefined(); + } finally { + await owner.dispose(); + await firstDone; + await server.close(); + } + } + ); + it('uses repaired full snapshot arguments, latest state, complete history and a single continuation', async () => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + 1 + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const done = owner.submit('one'); + try { + const first = await server.next(); + first.send( + lifecycle(first), + { + type: 'TOOL_CALL_START', + parentMessageId: 'a0', + toolCallId: 'c0', + toolCallName: 'f', + }, + { type: 'TOOL_CALL_ARGS', toolCallId: 'c0', delta: '{"value":' }, + { type: 'TOOL_CALL_END', toolCallId: 'c0' }, + { type: 'STATE_SNAPSHOT', snapshot: { latest: true } }, + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.body.messages, assistant(0, ' { "value": 7 } ')], + }, + lifecycle(first, 'RUN_FINISHED') + ); + const next = await server.next(1); + expect(handler.mock.calls[0][0]).toEqual({ value: 7 }); + expect(next.body).toMatchObject({ + threadId: 't', + state: { latest: true }, + tools: first.body.tools, + context: [], + forwardedProps: {}, + }); + expect(next.body.runId).not.toBe(first.body.runId); + expect(next.body.messages).toHaveLength(3); + expect(next.body.messages[2]).toMatchObject({ + role: 'tool', + toolCallId: 'c0', + content: '8', + }); + next.send(lifecycle(next), lifecycle(next, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('success'); + expect(owner.getSnapshot().clientTools.calls[0].delivery).toBe( + 'sent-unconfirmed' + ); + expect(owner.getSnapshot().run?.id).toBe(next.body.runId); + expect(server.exchanges).toHaveLength(2); + } finally { + await owner.dispose(); + await done; + await server.close(); + } + }); + it.each(['handler', 'arguments'] as const)( + 'bounds eleven physical requests and blocks the eleventh %s group before consuming it', + async (mode) => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const done = owner.submit('one'); + try { + for (let index = 0; index < 11; index++) { + const exchange = await server.next(index); + exchange.send( + lifecycle(exchange), + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + ...exchange.body.messages, + assistant( + index, + mode === 'arguments' ? '{"value":' : '{"value":1}' + ), + ], + }, + lifecycle(exchange, 'RUN_FINISHED') + ); + } + expect(await bounded(done)).toBe('interrupted'); + expect(handler).toHaveBeenCalledTimes(mode === 'arguments' ? 0 : 10); + expect(server.exchanges).toHaveLength(11); + expect(owner.getSnapshot().clientTools.calls).toHaveLength(10); + expect(owner.getSnapshot().clientTools.blocker).toMatchObject({ + reason: 'limit', + callIds: ['c10'], + }); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + } finally { + await owner.dispose(); + await done; + await server.close(); + } + } + ); + it('resumes a native continuation pause with exact retained results and physical correlation', async () => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const firstDone = owner.submit('one'); + try { + const first = await server.next(); + first.send( + lifecycle(first), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.body.messages, assistant(0)], + }, + lifecycle(first, 'RUN_FINISHED') + ); + const continuation = await server.next(1); + continuation.send(lifecycle(continuation), { + ...lifecycle(continuation, 'RUN_FINISHED'), + outcome: { + type: 'interrupt', + interrupts: [{ id: 'approve', reason: 'confirm' }], + }, + }); + expect(await bounded(firstDone)).toBe('paused'); + const paused = owner.getSnapshot(); + if (paused.decision?.kind !== 'native') + throw new Error('Missing native pause'); + expect(paused.run?.id).toBe(continuation.body.runId); + const done = owner.resume(paused.decision.id, [ + { interruptId: 'approve', status: 'resolved', payload: true }, + ]); + const resumed = await server.next(2); + expect(resumed.body.resume).toEqual([ + { interruptId: 'approve', status: 'resolved', payload: true }, + ]); + expect(resumed.body.messages).toEqual(continuation.body.messages); + resumed.send(lifecycle(resumed), lifecycle(resumed, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('success'); + expect(handler).toHaveBeenCalledOnce(); + expect(owner.getSnapshot().decision).toBeUndefined(); + } finally { + await owner.dispose(); + await firstDone; + await server.close(); + } + }); + it('allows correlated resume of an unresolved baseline then blocks its successful orphan', async () => { + const server = await serve(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: server.url }, + definition(handler) + ); + const firstDone = owner.submit('one'); + try { + const first = await server.next(); + first.send( + lifecycle(first), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.body.messages, assistant(0)], + }, + { + ...lifecycle(first, 'RUN_FINISHED'), + outcome: { + type: 'interrupt', + interrupts: [{ id: 'approve', reason: 'confirm' }], + }, + } + ); + expect(await bounded(firstDone)).toBe('paused'); + const paused = owner.getSnapshot(); + if (paused.decision?.kind !== 'native') + throw new Error('Missing native pause'); + const done = owner.resume(paused.decision.id, [ + { interruptId: 'approve', status: 'resolved', payload: true }, + ]); + const resumed = await server.next(1); + resumed.send(lifecycle(resumed), lifecycle(resumed, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('interrupted'); + expect(handler).not.toHaveBeenCalled(); + expect(owner.getSnapshot().decision).toBeUndefined(); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe( + 'unsupported' + ); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + } finally { + await owner.dispose(); + await firstDone; + await server.close(); + } + }); +}); diff --git a/libs/ag-ui/src/runtime/client-tools-session.spec.ts b/libs/ag-ui/src/runtime/client-tools-session.spec.ts new file mode 100644 index 000000000..3ec39b93d --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tools-session.spec.ts @@ -0,0 +1,1073 @@ +import { HttpAgent, PROTOCOL_VERSION, type RunAgentInput } from '@ag-ui/client'; +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; +import { createRun } from './create-run'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +async function bounded(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Client tool milestone timeout')), + 2000 + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} +function held() { + const requests: { + input: RunAgentInput; + send: (...events: unknown[]) => void; + end: () => void; + }[] = []; + const arrivals = new Map< + number, + ReturnType> + >(); + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const input = JSON.parse(String(init.body)) as RunAgentInput; + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const request = { + input, + send: (...events: unknown[]) => { + for (const event of events) + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + ); + }, + end: () => controller.close(), + }; + requests.push(request); + arrivals.get(requests.length - 1)?.resolve(request); + return new Response(body, { + headers: { 'content-type': 'text/event-stream' }, + }); + }); + return { + fetch, + requests, + next: (index = 0) => { + if (requests[index]) return Promise.resolve(requests[index]); + const arrival = + arrivals.get(index) ?? deferred<(typeof requests)[number]>(); + arrivals.set(index, arrival); + return bounded(arrival.promise); + }, + }; +} +function lifecycle(input: RunAgentInput, type = 'RUN_STARTED') { + return { type, threadId: input.threadId, runId: input.runId }; +} +function assistant(ids = ['c'], raw = '{"value":1}') { + return { + id: 'a', + role: 'assistant', + toolCalls: ids.map((id) => ({ + id, + type: 'function', + function: { name: 'f', arguments: raw }, + })), + }; +} +function tools( + handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + 1 + ), + followUp = true +) { + return { f: { description: 'f', parameters: {}, handler, followUp } }; +} +describe('native client tool command ownership', () => { + it.each( + (['stop', 'dispose', 'signal'] as const).flatMap((action) => + (['direct', 'nested', 'throwing', 'unencodable'] as const).map( + (shape) => ({ + action, + shape, + }) + ) + ) + )( + '$shape result getter $action invalidates completion before any result commit', + async ({ action, shape }) => { + const wire = held(); + const controller = new AbortController(); + let closed: Promise | undefined; + const read = vi.fn(() => { + if (action === 'signal') controller.abort(); + else closed = owner[action](); + if (shape === 'throwing') + throw new Error('copy failed after cancellation'); + return shape === 'unencodable' ? undefined : 3; + }); + const handler = vi.fn( + async (_args: { value: number }, _context: { signal: AbortSignal }) => { + expect(_args).toEqual({ value: 1 }); + expect(_context.signal.aborted).toBe(false); + return shape === 'nested' + ? { + nested: { + get value() { + return read(); + }, + }, + } + : { + get value() { + return read(); + }, + }; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + { f: { description: 'f', handler } } + ); + const observed: ReturnType[] = []; + owner.subscribe(() => { + observed.push(owner.getSnapshot()); + }); + const done = owner.submit('first', { signal: controller.signal }); + const first = await wire.next(); + try { + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('aborted'); + if (closed) await bounded(closed); + expect(read).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledOnce(); + expect(wire.requests).toHaveLength(1); + const value = owner.getSnapshot(); + expect(value.clientTools.calls[0]).toMatchObject({ + status: 'cancelled', + }); + expect(value.clientTools.calls[0]).not.toHaveProperty('result'); + expect(value.clientTools.calls[0]).not.toHaveProperty('error'); + expect(value.clientTools.blocker).toMatchObject({ + reason: 'cancelled', + callIds: ['c'], + }); + expect(value.clientTools.commandOutcome).toBe('aborted'); + expect(value.run?.outcome).toBe('success'); + expect( + value.transcript.some((message) => message.role === 'tool') + ).toBe(false); + expect( + observed.some((snapshot) => { + return ( + snapshot.clientTools.calls.some( + (call) => call.status === 'complete' || call.status === 'error' + ) || + snapshot.transcript.some((message) => message.role === 'tool') + ); + }) + ).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(owner.getSnapshot()).toBe(value); + if (action !== 'dispose') + expect(await owner.submit('bypass')).toBe('error'); + } finally { + await owner.dispose(); + await done; + } + } + ); + it.each(['stop', 'dispose', 'signal'] as const)( + 'result getter %s retains only earlier legitimate group results', + async (action) => { + const wire = held(); + const controller = new AbortController(); + let closed: Promise | undefined; + let count = 0; + const handler = vi.fn( + async (_args: { value: number }, _context: { signal: AbortSignal }) => { + expect(_args).toEqual({ value: 1 }); + expect(_context.signal.aborted).toBe(false); + count++; + if (count === 1) return { value: 1 }; + return { + get value() { + if (action === 'signal') controller.abort(); + else closed = owner[action](); + return 3; + }, + }; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + { f: { description: 'f', handler } } + ); + const done = owner.submit('first', { signal: controller.signal }); + const first = await wire.next(); + try { + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant(['c', 'd'])], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('aborted'); + if (closed) await bounded(closed); + expect( + owner.getSnapshot().clientTools.calls.map((call) => call.status) + ).toEqual(['complete', 'cancelled']); + expect(owner.getSnapshot().clientTools.calls[0].result).toEqual({ + value: 1, + }); + expect(owner.getSnapshot().clientTools.calls[1]).not.toHaveProperty( + 'result' + ); + expect(owner.getSnapshot().clientTools.blocker).toMatchObject({ + reason: 'cancelled', + callIds: ['d'], + }); + expect( + owner + .getSnapshot() + .transcript.filter((message) => message.role === 'tool') + ).toEqual([ + expect.objectContaining({ toolCallId: 'c', content: '{"value":1}' }), + ]); + expect(wire.requests).toHaveLength(1); + } finally { + await owner.dispose(); + await done; + } + } + ); + it('checks the private invocation guard at the actual fetch seam', async () => { + const fetch = vi.fn(); + const guard = vi.fn(() => false); + const event = vi.fn(); + const run = createRun({ url: 'unused', fetch }).start( + { + threadId: 't', + runId: 'r', + protocolVersion: PROTOCOL_VERSION, + messages: [], + state: {}, + tools: [], + context: [], + forwardedProps: {}, + }, + event, + undefined, + guard + ); + expect(await bounded(run.done)).toEqual({ + outcome: 'aborted', + fetchInvoked: false, + }); + expect(guard).toHaveBeenCalledOnce(); + expect(fetch).not.toHaveBeenCalled(); + expect(event).not.toHaveBeenCalled(); + }); + it('stop during an inconclusive physical-result callback still blocks bypass after fetch', async () => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools() + ); + let stopped: Promise | undefined; + owner.subscribe(() => { + if (!stopped && owner.getSnapshot().run?.outcome === 'error') + stopped = owner.stop(); + }); + const done = owner.submit('first'); + const first = await wire.next(); + first.send(lifecycle(first.input), { + type: 'RUN_ERROR', + message: 'failed', + }); + expect(await bounded(done)).toBe('aborted'); + await bounded(stopped!); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('uncertain'); + expect(await owner.submit('bypass')).toBe('error'); + await owner.dispose(); + }); + it.each([false, true])( + 'preserves a conclusive native cancellation, unresolved calls %s', + async (unresolved) => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools() + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + ...(unresolved + ? [ + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + ] + : []), + { + ...lifecycle(first.input, 'RUN_FINISHED'), + outcome: { type: 'cancelled' }, + } + ); + expect(await bounded(done)).toBe('aborted'); + expect(owner.getSnapshot().run?.outcome).toBe('aborted'); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe( + unresolved ? 'cancelled' : undefined + ); + expect(owner.getSnapshot().clientTools.calls).toEqual([]); + await owner.dispose(); + } + ); + it('settles a local result message collision without executing a continuation', async () => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue( + '00000000-0000-0000-0000-000000000000' + ); + return args.value; + } + ); + const owner = createSession( + { + threadId: 't', + url: 'unused', + fetch: wire.fetch, + messages: [ + { + id: '00000000-0000-0000-0000-000000000000', + role: 'user', + content: 'seed', + }, + ], + }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + try { + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('interrupted'); + expect(wire.requests).toHaveLength(1); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('conflict'); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + expect( + owner.getSnapshot().transcript.some((m) => m.role === 'tool') + ).toBe(false); + } finally { + vi.restoreAllMocks(); + await owner.dispose(); + } + }); + it('stop from physical-result publication wins the local command after native success', async () => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools() + ); + let stopped: Promise | undefined; + owner.subscribe(() => { + if (!stopped && owner.getSnapshot().run?.outcome === 'success') + stopped = owner.stop(); + }); + const done = owner.submit('first'); + const first = await wire.next(); + first.send(lifecycle(first.input), lifecycle(first.input, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('aborted'); + await bounded(stopped!); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + expect(owner.getSnapshot().clientTools.commandOutcome).toBe('aborted'); + await owner.dispose(); + }); + it('allows corrected submit after initial no-fetch preparation failure', async () => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools() + ); + const run = vi + .spyOn(HttpAgent.prototype, 'run') + .mockImplementationOnce(() => { + throw new Error('preparation'); + }); + try { + expect(await bounded(owner.submit('bad'))).toBe('error'); + expect(wire.fetch).not.toHaveBeenCalled(); + expect(owner.getSnapshot().clientTools.blocker).toBeUndefined(); + const done = owner.submit('corrected'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + } finally { + run.mockRestore(); + await owner.dispose(); + } + }); + it('retains unsent results and blocks a no-fetch continuation preparation failure', async () => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => { + vi.spyOn(HttpAgent.prototype, 'run').mockImplementationOnce(() => { + throw new Error('continuation preparation'); + }); + return args.value; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + try { + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('error'); + expect(wire.requests).toHaveLength(1); + expect(owner.getSnapshot().clientTools.calls[0]).toMatchObject({ + status: 'complete', + delivery: 'unsent', + }); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('uncertain'); + } finally { + vi.restoreAllMocks(); + await owner.dispose(); + } + }); + it.each(['empty-error', 'nonportable', 'unencodable'] as const)( + 'authors an explicit local error for %s and makes its bounded continuation', + async (mode) => { + const wire = held(); + // These intentionally untyped runtime results exercise the private storage + // boundary; the authored catalog's public declaration remains portable. + const handler = vi.fn( + async ( + _args: { value: number }, + _context: { signal: AbortSignal } + ): Promise => { + if (mode === 'empty-error') throw new Error(''); + return (mode === 'nonportable' + ? new Date() + : { missing: undefined }) as unknown as number; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + const next = await wire.next(1); + expect(owner.getSnapshot().clientTools.calls[0].status).toBe('error'); + expect(next.input.messages.at(-1)?.content).toMatch(/^Error: /); + next.send(lifecycle(next.input), lifecycle(next.input, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('success'); + await owner.dispose(); + } + ); + it.each(['terminal', 'pending', 'running', 'complete'] as const)( + 'rejects subscriber submit during %s before input and option getters', + async (stage) => { + const wire = held(); + const read = vi.fn(() => { + throw new Error('must not read'); + }); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(undefined, false) + ); + let nested: Promise | undefined; + owner.subscribe(() => { + const value = owner.getSnapshot(); + if ( + !nested && + (stage === 'terminal' + ? !!value.run?.terminal + : value.clientTools.calls[0]?.status === stage) + ) + nested = owner.submit( + { + get message() { + return read(); + }, + }, + { + get signal() { + return read(); + }, + } + ); + }); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + expect(await bounded(nested!)).toBe('error'); + expect(read).not.toHaveBeenCalled(); + expect( + owner.getSnapshot().transcript.filter((m) => m.role === 'user') + ).toHaveLength(1); + await owner.dispose(); + } + ); + it('handler submit is rejected and handler stop prevents publication of its returned value', async () => { + const wire = held(); + let nested!: Promise; + let stopped!: Promise; + const handler = vi.fn( + async (_args: { value: number }, _context: { signal: AbortSignal }) => { + nested = owner.submit('orphan'); + stopped = owner.stop(); + return 9; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('aborted'); + await bounded(stopped); + expect(await nested).toBe('error'); + expect( + owner.getSnapshot().transcript.filter((m) => m.role === 'user') + ).toHaveLength(1); + expect(owner.getSnapshot().transcript.some((m) => m.role === 'tool')).toBe( + false + ); + await owner.dispose(); + }); + it.each(['pending', 'running'] as const)( + 'dispose at %s prevents handler execution and settles', + async (stage) => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + let disposed: Promise | undefined; + owner.subscribe(() => { + if ( + !disposed && + owner.getSnapshot().clientTools.calls[0]?.status === stage + ) + disposed = owner.dispose(); + }); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('aborted'); + await bounded(disposed!); + expect(handler).not.toHaveBeenCalled(); + expect(owner.getSnapshot().clientTools.calls[0].status).toBe('cancelled'); + } + ); + it('malformed final JSON authors an error without decoded args or a handler', async () => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant(['c'], '{"value":')], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + const next = await wire.next(1); + expect(handler).not.toHaveBeenCalled(); + expect(owner.getSnapshot().clientTools.calls[0]).toMatchObject({ + status: 'error', + rawArguments: '{"value":', + error: 'Invalid JSON arguments', + }); + expect(owner.getSnapshot().clientTools.calls[0]).not.toHaveProperty('args'); + expect(next.input.messages.at(-1)?.content).toBe( + 'Error: Invalid JSON arguments' + ); + next.send(lifecycle(next.input), lifecycle(next.input, 'RUN_FINISHED')); + expect(await bounded(done)).toBe('success'); + await owner.dispose(); + }); + it.each(['RUN_ERROR', 'EOF', 'abort'] as const)( + 'keeps results sent-unconfirmed and blocks after continuation %s', + async (ending) => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools() + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + const next = await wire.next(1); + next.send(lifecycle(next.input)); + if (ending === 'RUN_ERROR') + next.send({ type: 'RUN_ERROR', message: 'failed' }); + else if (ending === 'EOF') next.end(); + else await bounded(owner.stop()); + expect(await bounded(done)).toBe( + ending === 'RUN_ERROR' + ? 'error' + : ending === 'EOF' + ? 'interrupted' + : 'aborted' + ); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('uncertain'); + expect(owner.getSnapshot().clientTools.calls[0]).toMatchObject({ + status: 'complete', + delivery: 'sent-unconfirmed', + }); + expect( + owner.getSnapshot().transcript.filter((m) => m.role === 'tool') + ).toHaveLength(1); + expect(await owner.submit('bypass')).toBe('error'); + await owner.dispose(); + } + ); + it.each([false, true])( + 'rejects busy submit before every getter, including empty catalog %s', + async (empty) => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + empty ? {} : tools() + ); + const first = owner.submit('first'); + await wire.next(); + const previous = owner.getSnapshot(); + const read = vi.fn(() => { + throw new Error('must not read'); + }); + expect( + await owner.submit( + { + get message() { + return read(); + }, + }, + { + get signal() { + return read(); + }, + } + ) + ).toBe('error'); + expect(read).not.toHaveBeenCalled(); + expect(owner.getSnapshot()).toBe(previous); + await bounded(owner.stop()); + expect(await bounded(first)).toBe('aborted'); + await owner.dispose(); + } + ); + it('runs snapshot-only calls and continues using a fresh physical id with no extra human', async () => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + 1 + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + const next = await wire.next(1); + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0]).toEqual({ value: 1 }); + expect(next.input.runId).not.toBe(first.input.runId); + expect(owner.getSnapshot().run?.id).toBe(next.input.runId); + expect(next.input.messages.filter((m) => m.role === 'user')).toHaveLength( + 1 + ); + expect(next.input.messages.at(-1)).toMatchObject({ + role: 'tool', + toolCallId: 'c', + content: '2', + }); + expect(owner.getSnapshot().clientTools.calls[0].delivery).toBe( + 'sent-unconfirmed' + ); + next.send( + lifecycle(next.input), + { type: 'MESSAGES_SNAPSHOT', messages: next.input.messages }, + lifecycle(next.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + expect(owner.getSnapshot().clientTools.calls[0].delivery).toBe('observed'); + await owner.dispose(); + }); + it.each(['terminal', 'pending', 'running', 'complete'] as const)( + 'queued stop at %s prevents the next side effect', + async (stage) => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + let stop: Promise | undefined; + owner.subscribe(() => { + const value = owner.getSnapshot(); + const call = value.clientTools.calls[0]; + if ( + !stop && + (stage === 'terminal' + ? !!value.run?.terminal + : call?.status === stage) + ) + stop = owner.stop(); + }); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant(['c', 'd'])], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('aborted'); + await bounded(stop!); + expect(handler).toHaveBeenCalledTimes(stage === 'complete' ? 1 : 0); + expect(wire.requests).toHaveLength(1); + expect(owner.getSnapshot().run?.terminal?.type).toBe('RUN_FINISHED'); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('cancelled'); + expect( + owner.getSnapshot().transcript.filter((m) => m.role === 'tool') + ).toHaveLength(stage === 'complete' ? 1 : 0); + await owner.dispose(); + } + ); + it.each(['resolve', 'reject'] as const)( + 'settles stop with a noncooperative handler and ignores late %s', + async (ending) => { + const wire = held(); + const late = deferred(); + const entered = deferred(); + const handler = vi.fn( + (_args: { value: number }, _context: { signal: AbortSignal }) => { + entered.resolve(); + return late.promise; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + await bounded(entered.promise); + await bounded(owner.stop()); + expect(await bounded(done)).toBe('aborted'); + const settled = owner.getSnapshot(); + if (ending === 'resolve') late.resolve(9); + else late.reject(new Error('late')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(owner.getSnapshot()).toBe(settled); + expect(wire.requests).toHaveLength(1); + expect(settled.clientTools.calls[0].status).toBe('cancelled'); + expect(settled.transcript.some((m) => m.role === 'tool')).toBe(false); + await owner.dispose(); + } + ); + it('dispose settles a noncooperative handler and leaves late values without publication authority', async () => { + const wire = held(); + const late = deferred(); + const entered = deferred(); + const handler = vi.fn( + (_args: { value: number }, _context: { signal: AbortSignal }) => { + entered.resolve(); + return late.promise; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + await bounded(entered.promise); + await bounded(owner.dispose()); + expect(await bounded(done)).toBe('aborted'); + const value = owner.getSnapshot(); + const notify = vi.fn(); + owner.subscribe(notify); + late.resolve(8); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(owner.getSnapshot()).toBe(value); + expect(notify).not.toHaveBeenCalled(); + expect(wire.requests).toHaveLength(1); + }); + it('partial group retains its completed result and rejects future human before getters', async () => { + const wire = held(); + const second = deferred(); + const entered = deferred(); + let count = 0; + const handler = vi.fn( + (_args: { value: number }, _context: { signal: AbortSignal }) => { + count++; + if (count === 1) return Promise.resolve(1); + entered.resolve(); + return second.promise; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant(['c', 'd'])], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + await bounded(entered.promise); + await bounded(owner.stop()); + expect(await bounded(done)).toBe('aborted'); + expect( + owner.getSnapshot().clientTools.calls.map((call) => call.status) + ).toEqual(['complete', 'cancelled']); + expect( + owner.getSnapshot().transcript.filter((m) => m.role === 'tool') + ).toHaveLength(1); + const read = vi.fn(() => { + throw new Error('must not read'); + }); + expect( + await owner.submit( + { + get message() { + return read(); + }, + }, + { + get signal() { + return read(); + }, + } + ) + ).toBe('error'); + expect(read).not.toHaveBeenCalled(); + second.resolve(2); + await owner.dispose(); + }); + it('uses reconciled transcript order when a final raw snapshot reverses surviving assistant ids', async () => { + const wire = held(); + const seen: number[] = []; + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => { + seen.push(args.value); + args.value = 99; + return seen.length; + } + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler, false) + ); + const done = owner.submit('first'); + const first = await wire.next(); + const a = assistant(['c'], '{"value":1}'); + const b = { ...assistant(['d'], '{"value":2}'), id: 'b' }; + first.send( + lifecycle(first.input), + { type: 'MESSAGES_SNAPSHOT', messages: [...first.input.messages, a, b] }, + { type: 'MESSAGES_SNAPSHOT', messages: [...first.input.messages, b, a] }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + expect(seen).toEqual([1, 2]); + expect( + owner.getSnapshot().clientTools.calls.map((call) => call.args) + ).toEqual([{ value: 1 }, { value: 2 }]); + await owner.dispose(); + }); + it('keeps all-false results unsent and carries them on the next explicit submit', async () => { + const wire = held(); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(undefined, false) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + lifecycle(first.input, 'RUN_FINISHED') + ); + expect(await bounded(done)).toBe('success'); + expect(wire.requests).toHaveLength(1); + expect(owner.getSnapshot().clientTools.calls[0].delivery).toBe('unsent'); + const second = owner.submit('next'); + const next = await wire.next(1); + expect(next.input.messages.filter((m) => m.role === 'tool')).toHaveLength( + 1 + ); + next.send(lifecycle(next.input), lifecycle(next.input, 'RUN_FINISHED')); + expect(await bounded(second)).toBe('success'); + await owner.dispose(); + }); + it('blocks an explicit empty pending list without rewriting native success', async () => { + const wire = held(); + const handler = vi.fn( + async (args: { value: number }, _context: { signal: AbortSignal }) => + args.value + ); + const owner = createSession( + { threadId: 't', url: 'unused', fetch: wire.fetch }, + tools(handler) + ); + const done = owner.submit('first'); + const first = await wire.next(); + first.send( + lifecycle(first.input), + { + type: 'MESSAGES_SNAPSHOT', + messages: [...first.input.messages, assistant()], + }, + { + ...lifecycle(first.input, 'RUN_FINISHED'), + outcome: { type: 'success', pendingToolCallIds: [] }, + } + ); + expect(await bounded(done)).toBe('interrupted'); + expect(handler).not.toHaveBeenCalled(); + expect(owner.getSnapshot().run?.outcome).toBe('success'); + expect(owner.getSnapshot().clientTools.commandOutcome).toBe('interrupted'); + expect(owner.getSnapshot().clientTools.blocker?.reason).toBe('unsupported'); + expect(await owner.submit('bypass')).toBe('error'); + await owner.dispose(); + }); +}); diff --git a/libs/ag-ui/src/runtime/client-tools.spec.ts b/libs/ag-ui/src/runtime/client-tools.spec.ts new file mode 100644 index 000000000..bb42b41d0 --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tools.spec.ts @@ -0,0 +1,182 @@ +import type { RunAgentInput } from '@ag-ui/client'; +import { describe, expect, it, vi } from 'vitest'; +import { createSession } from './create-session'; + +const options = { threadId: 'catalog', url: 'http://unused.invalid' }; +describe('private client tool catalog', () => { + it('preserves omitted, null, and primitive opaque parameters without inventing a schema', async () => { + const { captureClientTools, requestClientTools } = await import( + './client-tools' + ); + const handler = () => undefined; + const captured = captureClientTools({ + omitted: { description: 'omitted', handler }, + nil: { description: 'null', parameters: null, handler }, + scalar: { + description: 'scalar', + parameters: 'opaque', + followUp: false, + handler, + }, + }); + expect(requestClientTools(captured)).toEqual([ + { name: 'omitted', description: 'omitted' }, + { name: 'nil', description: 'null', parameters: null }, + { name: 'scalar', description: 'scalar', parameters: 'opaque' }, + ]); + expect(captured.get('scalar')?.followUp).toBe(false); + }); + it('enables an immutable empty projection only when a catalog is supplied, without fetching', async () => { + const fetch = vi.fn(); + const plain = createSession({ ...options, fetch }); + const enabled = createSession({ ...options, fetch }, {}); + expect(plain.getSnapshot()).not.toHaveProperty('clientTools'); + const projection = enabled.getSnapshot().clientTools; + expect(projection).toEqual({ calls: [] }); + expect(Object.isFrozen(projection)).toBe(true); + expect(Object.isFrozen(projection.calls)).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + await enabled.dispose(); + await plain.dispose(); + }); + it('captures only selected own definitions and fields once, retaining handlers and opaque metadata', async () => { + const { captureClientTools, requestClientTools } = await import( + './client-tools' + ); + const reads: string[] = []; + const handler = vi.fn((args: { city: string }) => args.city); + const metadata = { unusual: ['opaque'], type: 'invented-schema' }; + const definition = { + get description() { + reads.push('description'); + return 'weather'; + }, + get parameters() { + reads.push('parameters'); + return metadata; + }, + get followUp() { + reads.push('followUp'); + return undefined; + }, + get handler() { + reads.push('handler'); + return handler; + }, + get idempotent() { + throw new Error('unselected idempotent'); + }, + get unrelated() { + throw new Error('unselected property'); + }, + }; + const catalog = Object.create({ inherited: definition }); + Object.defineProperty(catalog, 'weather', { + enumerable: true, + get() { + reads.push('weather'); + return definition; + }, + }); + const captured = captureClientTools(catalog); + expect(reads).toEqual([ + 'weather', + 'description', + 'parameters', + 'followUp', + 'handler', + ]); + expect(captured.size).toBe(1); + expect(captured.get('weather')?.handler).toBe(handler); + expect(captured.get('weather')?.followUp).toBe(true); + metadata.unusual.push('caller mutation'); + const first = requestClientTools(captured); + expect(first).toEqual([ + { + name: 'weather', + description: 'weather', + parameters: { unusual: ['opaque'], type: 'invented-schema' }, + }, + ]); + (first[0].parameters['unusual'] as string[]).push('request mutation'); + first[0].description = 'request mutation'; + expect(requestClientTools(captured)[0]).toEqual({ + name: 'weather', + description: 'weather', + parameters: { unusual: ['opaque'], type: 'invented-schema' }, + }); + expect(reads).toHaveLength(5); + expect(handler).not.toHaveBeenCalled(); + }); + it('sends the constructor catalog on every actual submitted request without executing handlers', async () => { + const bodies: RunAgentInput[] = []; + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const input = JSON.parse(String(init.body)) as RunAgentInput; + bodies.push(input); + return new Response( + [ + { type: 'RUN_STARTED', threadId: input.threadId, runId: input.runId }, + { + type: 'RUN_FINISHED', + threadId: input.threadId, + runId: input.runId, + }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(''), + { headers: { 'content-type': 'text/event-stream' } } + ); + }); + const handler = vi.fn((args: { city: string }) => args.city); + const catalog = { + weather: { + description: 'original', + parameters: { city: ['original'] }, + followUp: false, + handler: handler as (args: { city: string }) => string, + }, + }; + const owner = createSession({ ...options, fetch }, catalog); + catalog.weather.description = 'changed'; + catalog.weather.parameters.city.push('changed'); + catalog.weather.handler = () => 'changed'; + expect(await owner.submit('first')).toBe('success'); + expect(bodies[0].tools).toHaveLength(1); + bodies[0].tools[0].description = 'changed request'; + expect(await owner.submit('second')).toBe('success'); + expect(bodies[1].tools).toEqual([ + { + name: 'weather', + description: 'original', + parameters: { city: ['original'] }, + }, + ]); + expect(owner.getSnapshot().clientTools).toEqual({ + calls: [], + commandOutcome: 'success', + }); + expect(owner.getSnapshot().run?.terminal?.type).toBe('RUN_FINISHED'); + expect(handler).not.toHaveBeenCalled(); + await owner.dispose(); + }); + it.each([ + { description: 3, handler: () => 1 }, + { description: 'tool', handler: 3 }, + { description: 'tool', handler: () => 1, followUp: 'yes' }, + { description: 'tool', handler: () => 1, parameters: new Date() }, + { + description: 'tool', + handler: () => 1, + parameters: { callback: () => 1 }, + }, + ])( + 'rejects incompatible selected catalog data before any fetch', + (definition) => { + const fetch = vi.fn(); + expect(() => + createSession({ ...options, fetch }, { invalid: definition } as never) + ).toThrow(TypeError); + expect(fetch).not.toHaveBeenCalled(); + } + ); +}); diff --git a/libs/ag-ui/src/runtime/client-tools.ts b/libs/ag-ui/src/runtime/client-tools.ts new file mode 100644 index 000000000..0479f3772 --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tools.ts @@ -0,0 +1,93 @@ +import type { Tool } from '@ag-ui/client'; +import type { + CompleteOutcome, + DeepReadonly, + ToolContract, +} from '@threadplane/core'; +import type { FunctionToolDefinition } from '@threadplane/core/tools'; +import { copyData } from '../lib/internal/copy-data.js'; +import type { SessionSnapshot } from './session-observation.js'; + +export type ClientToolCall> = { + [K in keyof C & string]: { + readonly name: K; + readonly callId: string; + readonly assistantMessageId: string; + readonly sourceRunId: string; + readonly rawArguments: string; + readonly args?: DeepReadonly; + readonly result?: DeepReadonly; + readonly error?: string; + readonly status: 'pending' | 'running' | 'complete' | 'error' | 'cancelled'; + readonly delivery: 'unsent' | 'sent-unconfirmed' | 'observed'; + readonly provenance: 'local'; + }; +}[keyof C & string]; + +export interface ClientToolBlocker { + readonly reason: + | 'unsupported' + | 'conflict' + | 'cancelled' + | 'uncertain' + | 'limit'; + readonly sourceRunId: string; + readonly callIds: readonly string[]; +} + +export interface ClientToolSessionSnapshot< + C extends Record +> extends SessionSnapshot { + readonly clientTools: { + readonly calls: readonly ClientToolCall[]; + readonly blocker?: ClientToolBlocker; + readonly commandOutcome?: CompleteOutcome; + }; +} + +interface CapturedClientTool { + readonly description: string; + readonly parameters: unknown; + readonly followUp: boolean; + readonly handler: FunctionToolDefinition['handler']; +} + +/** Select fields before ownership copying: unrelated metadata grants no authority. */ +export function captureClientTools( + tools: Record +): ReadonlyMap { + const captured = new Map(); + for (const name of Object.keys(tools)) { + const definition = tools[name]; + const { description, parameters, followUp = true, handler } = definition; + if ( + typeof description !== 'string' || + typeof followUp !== 'boolean' || + typeof handler !== 'function' + ) + throw new TypeError('Invalid client tool definition'); + captured.set( + name, + Object.freeze({ + description, + parameters: copyData(parameters, true), + followUp, + handler, + }) + ); + } + return captured; +} + +/** Each physical request owns independent mutable wire metadata. */ +export function requestClientTools( + tools: ReadonlyMap +): Tool[] { + return [...tools].map(([name, definition]) => ({ + name, + description: definition.description, + ...(definition.parameters !== undefined && { + parameters: copyData(definition.parameters, false) as Tool['parameters'], + }), + })); +} diff --git a/libs/ag-ui/src/runtime/client-tools.type-test.ts b/libs/ag-ui/src/runtime/client-tools.type-test.ts new file mode 100644 index 000000000..0c7fb072e --- /dev/null +++ b/libs/ag-ui/src/runtime/client-tools.type-test.ts @@ -0,0 +1,97 @@ +import { + createSession, + type ClientToolSession, + type Session, + type SessionOptions, +} from './create-session'; +import type { ClientToolCall } from './client-tools'; + +declare const options: SessionOptions; +const catalog = { + weather: { + description: 'weather', + handler: (args: { city: string; units?: 'C' | 'F' }) => ({ + temperatures: [args.city.length], + }), + }, + book: { + description: 'book', + handler: async (args: { seats: number }) => ({ + confirmation: String(args.seats), + }), + }, +}; +const owner = createSession(options, catalog); +const plain: Session = createSession(options); +// @ts-expect-error plain owners expose no local execution projection +void plain.getSnapshot().clientTools; +// @ts-expect-error catalog is not an options field +createSession({ ...options, tools: catalog }); +for (const call of owner.getSnapshot().clientTools.calls) { + const name: 'weather' | 'book' = call.name; + void name; + if (call.name === 'weather') { + const city: string | undefined = call.args?.city; + const temperatures: readonly number[] | undefined = + call.result?.temperatures; + void city; + void temperatures; + // @ts-expect-error heterogeneous result stays narrowed + void call.result?.confirmation; + // @ts-expect-error authored nested arguments are readonly + if (call.args) call.args.city = 'changed'; + // @ts-expect-error authored nested results are readonly + call.result?.temperatures.push(3); + } else { + const seats: number | undefined = call.args?.seats; + const confirmation: string | undefined = call.result?.confirmation; + void seats; + void confirmation; + // @ts-expect-error heterogeneous arguments stay narrowed + void call.args?.city; + } + // @ts-expect-error local call fields are readonly + call.delivery = 'observed'; +} +// @ts-expect-error snapshot calls cannot be mutated +owner.getSnapshot().clientTools.calls.push({}); +const empty = createSession(options, {}); +const impossible: readonly never[] = empty.getSnapshot().clientTools.calls; +void impossible; +const declared: ClientToolSession<{ + weather: { args: { city: string }; result: { temperatures: number[] } }; +}> = createSession(options, { weather: catalog.weather }); +void declared; +const malformed: ClientToolCall<{ + weather: { args: { city: string }; result: number }; +}> = { + name: 'weather', + callId: 'call', + assistantMessageId: 'assistant', + sourceRunId: 'run', + rawArguments: '{', + status: 'error', + delivery: 'unsent', + provenance: 'local', + error: 'invalid JSON', +}; +// @ts-expect-error malformed JSON has no required decoded arguments +const required: { readonly args: { readonly city: string } } = malformed; +void required; +// @ts-expect-error optional arguments still enforce authored field types +const wrong: typeof malformed = { ...malformed, args: { city: 1 } }; +void wrong; +createSession(options, { + // @ts-expect-error inferred functions are not portable arguments + bad: { + description: '', + handler: (args: { callback: () => void }) => args.callback(), + }, +}); +createSession(options, { + // @ts-expect-error inferred functions are not portable results + bad: { + description: '', + handler: (args: { city: string }) => () => args.city, + }, +}); diff --git a/libs/ag-ui/src/runtime/create-run.ts b/libs/ag-ui/src/runtime/create-run.ts index 251fb71ec..1ef468b01 100644 --- a/libs/ag-ui/src/runtime/create-run.ts +++ b/libs/ag-ui/src/runtime/create-run.ts @@ -56,7 +56,8 @@ export function createRun( start( input: RunAgentInput, onEvent: (event: BaseEvent) => void, - signal?: AbortSignal + signal?: AbortSignal, + invocationGuard?: () => boolean ): RunHandle { let resolve!: (result: RunResult) => void; const done = new Promise((yes) => { @@ -140,6 +141,11 @@ export function createRun( }, undefined, () => { + if (closed) return false; + if (invocationGuard && !invocationGuard()) { + abort(); + return false; + } if (closed) return false; fetchInvoked = true; return true; diff --git a/libs/ag-ui/src/runtime/create-session.ts b/libs/ag-ui/src/runtime/create-session.ts index f59d7d46a..c21f8535e 100644 --- a/libs/ag-ui/src/runtime/create-session.ts +++ b/libs/ag-ui/src/runtime/create-session.ts @@ -6,8 +6,23 @@ import { type RunAgentInput, type ResumeEntry, } from '@ag-ui/client'; -import type { CompleteOutcome } from '@threadplane/core'; -import { ownTranscript, requestMessages, type Transcript } from './transcript.js'; +import type { CompleteOutcome, ToolContract } from '@threadplane/core'; +import type { + CheckedTools, + FunctionToolDefinition, + ToolContracts, +} from '@threadplane/core/tools'; +import { + captureClientTools, + requestClientTools, + type ClientToolSessionSnapshot, +} from './client-tools.js'; +import { createClientToolLedger } from './client-tool-ledger.js'; +import { + ownTranscript, + requestMessages, + type Transcript, +} from './transcript.js'; import { requestState } from './state.js'; import { applyObservation, @@ -55,6 +70,12 @@ export interface Session { stop(): Promise; dispose(): Promise; } +export type ClientToolSession> = Omit< + Session, + 'getSnapshot' +> & { + getSnapshot(): ClientToolSessionSnapshot; +}; interface Attempt { readonly id: string; readonly controller: AbortController; @@ -67,7 +88,15 @@ interface Attempt { } /** Private protocol owner. This does not yet implement core AgentSession. */ -export function createSession(options: SessionOptions): Session { +export function createSession>( + options: SessionOptions, + tools: T & CheckedTools +): ClientToolSession>; +export function createSession(options: SessionOptions): Session; +export function createSession( + options: SessionOptions, + tools?: Record +): Session { const { threadId, url, @@ -78,8 +107,22 @@ export function createSession(options: SessionOptions): Session { interruptMode = 'native', } = options; if (!threadId) throw new TypeError('A nonempty threadId is required'); + const catalog = tools === undefined ? undefined : captureClientTools(tools); + if (catalog !== undefined) + return createToolsOwner( + { threadId, url, headers, fetch, messages, state, interruptMode }, + catalog + ); const factory = createRun({ url, headers, fetch, interruptMode }); - const publication = createPublication(initialObservation(messages, state)); + const initial = initialObservation(messages, state); + const publication = createPublication( + catalog === undefined + ? initial + : Object.freeze({ + ...initial, + clientTools: Object.freeze({ calls: Object.freeze([]) }), + }) + ); let active: Attempt | undefined; let disposed = false; let revision = 0; @@ -135,7 +178,7 @@ export function createSession(options: SessionOptions): Session { protocolVersion: PROTOCOL_VERSION, messages: requestMessages(value.transcript), state: requestState(value.state), - tools: [], + tools: catalog === undefined ? [] : requestClientTools(catalog), context: [], forwardedProps: {}, ...(decision?.kind === 'native' && @@ -433,3 +476,780 @@ export function createSession(options: SessionOptions): Session { dispose: () => close(true), }; } + +/** A command can own several physical runs, but never lends execution authority + * to a superseding submit. The legacy no-catalog owner above remains separate. */ +function createToolsOwner( + options: SessionOptions, + catalog: ReturnType +): Session { + const { threadId, interruptMode = 'native' } = options; + const factory = createRun(options); + const initial = initialObservation(options.messages, options.state); + const ledger = createClientToolLedger>( + [...catalog.keys()], + initial.transcript + ); + const publication = createPublication( + Object.freeze({ + ...initial, + clientTools: ledger.snapshot(), + }) + ); + interface Physical { + readonly id: string; + readonly token: object; + handle?: RunHandle; + starting: boolean; + settled: boolean; + fetchInvoked: boolean; + } + interface Command { + readonly controller: AbortController; + readonly done: Promise; + readonly resolve: (outcome: CompleteOutcome) => void; + cleanup: () => void; + physical?: Physical; + groups: number; + finished: boolean; + } + let active: Command | undefined; + let disposed = false; + let revision = 0; + let pausedPhysical: + | { readonly id: string; readonly token: object } + | undefined; + const owns = (command: Command, physical?: Physical) => + active === command && + !command.finished && + (!physical || command.physical === physical); + const eligible = (command: Command, physical?: Physical) => + owns(command, physical) && + !disposed && + !command.controller.signal.aborted && + !ledger.snapshot().blocker && + !publication.getSnapshot().decision; + const publishLedger = (outcome?: CompleteOutcome) => { + const previous = publication.getSnapshot(); + publication.publish( + Object.freeze({ + ...previous, + transcript: ledger.retainHistory(previous.transcript), + clientTools: Object.freeze({ + ...ledger.snapshot(), + ...(outcome && { commandOutcome: outcome }), + }), + }) + ); + }; + const block = ( + reason: 'cancelled' | 'uncertain' | 'limit', + command: Command, + ids: readonly string[] = [] + ) => { + ledger.installBlocker({ + reason, + callIds: ids, + sourceRunId: command.physical?.id ?? '', + }); + }; + const unresolvedRootIds = () => { + const history = publication.getSnapshot().transcript; + return history.flatMap((message) => + message.role === 'assistant' && typeof message.subagentRunId !== 'string' + ? (message.toolCalls ?? []) + .filter( + (call) => + !history.some( + (row) => + row.role === 'tool' && + row.toolCallId === call.id && + typeof row.subagentRunId !== 'string' + ) + ) + .map((call) => call.id) + : [] + ); + }; + const protectCancellation = (command: Command, physical: Physical) => { + ledger.cancelIncomplete(physical.id); + const snapshot = publication.getSnapshot(); + if (snapshot.decision) return; + const terminal = snapshot.run?.terminal; + const conclusive = + terminal?.type === 'RUN_FINISHED' && + (terminal.outcome == null || + terminal.outcome.type === 'success' || + terminal.outcome.type === 'cancelled'); + if (conclusive) { + const unresolved = unresolvedRootIds(); + if (unresolved.length) block('cancelled', command, unresolved); + } else if (physical.fetchInvoked || command.groups > 0) + block('uncertain', command); + }; + const finish = (command: Command, outcome: CompleteOutcome) => { + if (!owns(command)) return; + command.finished = true; + command.cleanup(); + // Keep admission busy through final notification; reentrant submit cannot + // append a new human while this command's result is being published. + const previous = publication.getSnapshot(); + publication.publish( + Object.freeze({ + ...previous, + transcript: ledger.retainHistory(previous.transcript), + status: + outcome === 'error' || outcome === 'interrupted' ? 'error' : 'idle', + clientTools: Object.freeze({ + ...ledger.snapshot(), + commandOutcome: outcome, + }), + }) + ); + active = undefined; + if (disposed) publication.clear(); + command.resolve(outcome); + }; + const cancel = (command: Command) => { + if (!owns(command)) return; + command.controller.abort(); + const physical = command.physical; + if (physical && !physical.settled) { + physical.handle?.abort(); + // Its result callback owns native decision settlement, including starts + // whose physical handle is not assigned yet. + if (physical.starting || physical.handle) return; + } + if (physical?.settled) protectCancellation(command, physical); + else ledger.cancelIncomplete(physical?.id ?? ''); + finish(command, 'aborted'); + }; + const cancelledResult = Symbol('cancelled handler'); + const admittedDefinition = (id: string) => { + const call = ledger.snapshot().calls.find((call) => call.callId === id); + const definition = call && catalog.get(call.name); + if (!call || !definition) throw new TypeError('No admitted client tool'); + return { call, definition }; + }; + const invoke = async (command: Command, action: () => unknown) => { + const signal = command.controller.signal; + if (signal.aborted) return cancelledResult; + let abort!: () => void; + const cancelled = new Promise((resolve) => { + abort = () => resolve(cancelledResult); + signal.addEventListener('abort', abort, { once: true }); + }); + // Promise adoption observes late rejection even when cancellation wins. + const result = Promise.resolve().then(() => { + if (!eligible(command)) return cancelledResult; + return action(); + }); + try { + return await Promise.race([result, cancelled]); + } finally { + signal.removeEventListener('abort', abort); + } + }; + const execute = async ( + command: Command, + physical: Physical, + group: readonly string[] + ) => { + for (const id of group) { + if (!eligible(command, physical)) return; + const { call, definition } = admittedDefinition(id); + let value: unknown; + let error = call.error; + if (error === undefined) { + publication.command(() => { + if (eligible(command, physical)) { + ledger.markRunning(id); + publishLedger(); + } + }); + // Publications have drained all queued stop/dispose/submit commands. + if (!eligible(command, physical)) return; + try { + value = await invoke(command, () => + definition.handler(ledger.argumentsFor(id) as never, { + signal: command.controller.signal, + }) + ); + } catch (failure) { + try { + const message = + failure instanceof Error ? failure.message : undefined; + error = + typeof message === 'string' + ? message + : 'Client tool handler failed'; + } catch { + error = 'Client tool handler failed'; + } + } + } + if (value === cancelledResult || !eligible(command, physical)) return; + publication.command(() => { + if (!eligible(command, physical)) return; + // Result getters run while this command queue is draining. Stop, + // disposal and caller abort register intent synchronously via revision, + // before their queued cancellation can mutate the command or ledger. + const completionRevision = revision; + const completionAuthority = () => + revision === completionRevision && eligible(command, physical); + try { + const history = publication.getSnapshot().transcript; + if (error !== undefined) + ledger.fail( + id, + crypto.randomUUID(), + error, + history, + completionAuthority + ); + else + ledger.complete( + id, + crypto.randomUUID(), + value, + history, + completionAuthority + ); + if (completionAuthority()) publishLedger(); + } catch { + // A queued cancellation owns settlement; do not relabel that intent + // as a conflict or retain a result authored during its ownership copy. + if (revision !== completionRevision) return; + if ( + owns(command, physical) && + !command.controller.signal.aborted && + !disposed + ) { + ledger.installBlocker({ + reason: 'conflict', + sourceRunId: physical.id, + callIds: [id], + }); + ledger.cancelIncomplete(physical.id); + finish(command, 'interrupted'); + } + } + }); + if (!eligible(command, physical)) return; + } + publication.command(() => { + if (!eligible(command, physical)) return; + if (!group.some((id) => admittedDefinition(id).definition.followUp)) { + finish(command, 'success'); + return; + } + startPhysical(command, crypto.randomUUID()); + }); + }; + const settlePhysical = ( + command: Command, + physical: Physical, + outcome: CompleteOutcome, + fetchInvoked: boolean + ) => { + if (physical.settled) return; + physical.settled = true; + physical.fetchInvoked = fetchInvoked; + if (!owns(command, physical)) return; + const previous = publication.getSnapshot(); + const terminal = previous.run?.terminal; + if ( + previous.decision?.kind === 'native' && + terminal?.type === 'RUN_FINISHED' && + terminal.outcome?.type === 'interrupt' + ) + pausedPhysical = physical; + const nativeSuccess = + terminal?.type === 'RUN_FINISHED' && + (terminal.outcome == null || terminal.outcome.type === 'success'); + publication.publish( + Object.freeze({ + ...previous, + decision: settleDecision( + previous.decision, + { id: physical.id, outcome, terminal }, + fetchInvoked + ), + run: Object.freeze({ + ...previous.run, + id: physical.id, + outcome: nativeSuccess ? 'success' : outcome, + }), + clientTools: ledger.snapshot(), + }) + ); + // Observer commands queued by native result publication precede local tool + // admission or command completion, including stop on a success callback. + publication.command(() => { + if (!owns(command, physical)) return; + if (command.controller.signal.aborted || disposed) { + protectCancellation(command, physical); + finish(command, 'aborted'); + return; + } + if (publication.getSnapshot().decision || outcome === 'paused') { + finish(command, outcome); + return; + } + if (outcome !== 'success') { + if ( + terminal?.type === 'RUN_FINISHED' && + terminal.outcome?.type === 'cancelled' + ) + protectCancellation(command, physical); + else if (fetchInvoked || command.groups > 0) + block('uncertain', command); + finish(command, outcome); + return; + } + if (ledger.snapshot().blocker) { + finish(command, 'interrupted'); + return; + } + // The ledger consumes the whole group before observers can issue commands. + const group = ledger.admitSuccess(physical.token, command.groups < 10); + if (ledger.snapshot().blocker) { + finish(command, 'interrupted'); + return; + } + if (!group.length) { + finish(command, 'success'); + return; + } + command.groups++; + publishLedger(); + queueMicrotask(() => { + if (eligible(command, physical)) + void execute(command, physical, group).catch(() => { + publication.command(() => { + if ( + !owns(command, physical) || + command.controller.signal.aborted || + disposed + ) + return; + ledger.installBlocker({ + reason: 'conflict', + sourceRunId: physical.id, + callIds: group, + }); + ledger.cancelIncomplete(physical.id); + finish(command, 'interrupted'); + }); + }); + }); + }); + }; + const dispatch = (command: Command, physical: Physical) => { + if ( + !owns(command, physical) || + command.controller.signal.aborted || + disposed + ) + return; + let input: RunAgentInput; + let capture: ReturnType; + try { + const snapshot = publication.getSnapshot(); + const resuming = + snapshot.decision?.kind === 'native' && + snapshot.decision.attempt?.runId === physical.id; + if ( + resuming && + (!pausedPhysical || + snapshot.decision?.sourceRunId !== pausedPhysical.id) + ) + throw new TypeError('Resume does not match owned physical pause'); + capture = + resuming && pausedPhysical + ? ledger.captureNativeResumeRequest( + snapshot.transcript, + pausedPhysical.token + ) + : ledger.captureRequest(snapshot.transcript); + input = { + threadId, + runId: physical.id, + protocolVersion: PROTOCOL_VERSION, + messages: requestMessages(capture.transcript), + state: requestState(snapshot.state), + tools: requestClientTools(catalog), + context: [], + forwardedProps: {}, + ...(snapshot.decision?.kind === 'native' && + snapshot.decision.attempt?.runId === physical.id && { + resume: copyData( + snapshot.decision.attempt.responses, + false + ) as ResumeEntry[], + }), + }; + } catch { + publication.command(() => + settlePhysical( + command, + physical, + command.controller.signal.aborted ? 'aborted' : 'error', + false + ) + ); + return; + } + if ( + !owns(command, physical) || + command.controller.signal.aborted || + disposed + ) + return; + physical.starting = true; + physical.handle = factory.start( + input, + (event) => { + publication.command(() => { + if ( + !owns(command, physical) || + command.controller.signal.aborted || + disposed + ) + return; + const previous = publication.getSnapshot(); + let next = applyObservation( + previous, + event as AGUIEvent, + interruptMode + ); + ledger.observe(physical.token, event as AGUIEvent, next.transcript); + const evidence = + next.run?.terminal !== previous.run?.terminal + ? next.run?.terminal + : next.run?.legacyInterrupt !== previous.run?.legacyInterrupt + ? next.run?.legacyInterrupt + : undefined; + if (evidence) + next = Object.freeze({ + ...next, + decision: observeDecision( + previous.decision, + physical.id, + evidence, + evidence.type === 'RUN_FINISHED' && + evidence.outcome?.type === 'interrupt' + ? (crypto.randomUUID() as PauseId) + : undefined + ), + }); + if ( + owns(command, physical) && + !command.controller.signal.aborted && + !disposed + ) + publication.publish( + Object.freeze({ + ...next, + transcript: ledger.retainHistory(next.transcript), + clientTools: ledger.snapshot(), + }) + ); + }); + }, + command.controller.signal, + () => { + if ( + !owns(command, physical) || + disposed || + command.controller.signal.aborted || + ledger.snapshot().blocker + ) + return false; + const decision = publication.getSnapshot().decision; + if ( + decision && + !( + decision.kind === 'native' && + decision.attempt?.runId === physical.id + ) + ) + return false; + ledger.markFetchInvoked(capture); + // No publication here: the guard is adjacent to actual fetch invocation. + return ( + owns(command, physical) && + !disposed && + !command.controller.signal.aborted + ); + } + ); + physical.starting = false; + if ( + owns(command, physical) && + !disposed && + !command.controller.signal.aborted + ) + publication.command(() => { + if (owns(command, physical)) publishLedger(); + }); + if ( + !owns(command, physical) || + command.controller.signal.aborted || + disposed + ) + physical.handle.abort(); + void physical.handle.done.then((result) => + publication.command(() => + settlePhysical(command, physical, result.outcome, result.fetchInvoked) + ) + ); + }; + const startPhysical = (command: Command, id: string) => { + const previous = publication.getSnapshot(); + const physical: Physical = { + id, + token: ledger.startPhysical({ + runId: id, + threadId, + transcript: previous.transcript, + }), + starting: false, + settled: false, + fetchInvoked: false, + }; + command.physical = physical; + publication.publish( + Object.freeze({ + ...previous, + status: 'running', + subagents: Object.freeze([]), + run: Object.freeze({ id }), + clientTools: ledger.snapshot(), + }) + ); + queueMicrotask(() => dispatch(command, physical)); + }; + const admit = ( + next: SessionSnapshot, + id: string, + signal: AbortSignal | undefined, + resolve: (outcome: CompleteOutcome) => void + ) => { + let settle!: (outcome: CompleteOutcome) => void; + const done = new Promise((yes) => { + settle = yes; + }); + const command: Command = { + controller: new AbortController(), + done, + resolve: settle, + cleanup: () => undefined, + groups: 0, + finished: false, + }; + active = command; + revision++; + void done.then(resolve); + const abort = () => { + revision++; + publication.command(() => cancel(command)); + }; + signal?.addEventListener('abort', abort, { once: true }); + command.cleanup = () => signal?.removeEventListener('abort', abort); + const physical: Physical = { + id, + token: ledger.startPhysical({ + runId: id, + threadId, + transcript: next.transcript, + }), + starting: false, + settled: false, + fetchInvoked: false, + }; + command.physical = physical; + publication.publish( + Object.freeze({ ...next, clientTools: ledger.snapshot() }) + ); + queueMicrotask(() => dispatch(command, physical)); + }; + const close = (permanent: boolean) => { + revision++; + return new Promise((resolve) => + publication.command(() => { + if (permanent) disposed = true; + const command = active; + if (command) { + cancel(command); + void command.done.then(() => resolve()); + } else { + if (disposed) publication.clear(); + resolve(); + } + }) + ); + }; + return { + getSnapshot: publication.getSnapshot, + subscribe: (notify) => + disposed ? () => undefined : publication.subscribe(notify), + stop: () => close(false), + dispose: () => close(true), + submit: (input, options) => + new Promise((resolve) => { + if (disposed) { + resolve('aborted'); + return; + } + // Even options.signal is caller code. Busy/blocker checks precede it. + if ( + active || + ledger.snapshot().blocker || + publication.getSnapshot().decision + ) { + resolve('error'); + return; + } + const before = revision; + let signal: AbortSignal | undefined; + let captured: ReturnType; + try { + signal = options?.signal; + if (disposed || signal?.aborted || revision !== before) { + resolve('aborted'); + return; + } + captured = captureSubmit(input); + } catch { + resolve( + disposed || signal?.aborted || revision !== before + ? 'aborted' + : 'error' + ); + return; + } + if (disposed || signal?.aborted || revision !== before) { + resolve('aborted'); + return; + } + publication.command(() => { + if (disposed || signal?.aborted || revision !== before) { + resolve('aborted'); + return; + } + if ( + active || + ledger.snapshot().blocker || + publication.getSnapshot().decision + ) { + resolve('error'); + return; + } + try { + const previous = publication.getSnapshot(); + if (!ledger.historyIntegrity(previous.transcript)) { + publishLedger(); + resolve('error'); + return; + } + const state = mergeSubmitState(previous.state, captured.state); + const userId = crypto.randomUUID(); + if (previous.transcript.some((message) => message.id === userId)) + throw new TypeError('Generated message ID collides with history'); + const id = crypto.randomUUID(); + const user = ownTranscript([ + { id: userId, role: 'user', content: captured.message }, + ])[0]; + admit( + Object.freeze({ + ...previous, + state, + status: 'running', + transcript: Object.freeze([...previous.transcript, user]), + subagents: Object.freeze([]), + run: Object.freeze({ id }), + }), + id, + signal, + resolve + ); + } catch { + resolve('error'); + } + }); + }), + resume: (pause, responses, options) => + new Promise((resolve) => { + if (disposed) { + resolve('aborted'); + return; + } + if (active || ledger.snapshot().blocker) { + resolve('error'); + return; + } + const before = revision; + let signal: AbortSignal | undefined; + let captured: readonly NativeResponse[]; + try { + signal = options?.signal; + if (disposed || signal?.aborted || revision !== before) { + resolve('aborted'); + return; + } + if (interruptMode !== 'native') + throw new TypeError('Native resume required'); + const previous = publication.getSnapshot(); + assertResumeEligible( + previous.decision, + previous.run, + pause, + Date.now() + ); + captured = captureResponses(responses); + } catch { + resolve( + disposed || signal?.aborted || revision !== before + ? 'aborted' + : 'error' + ); + return; + } + publication.command(() => { + if (disposed || signal?.aborted || revision !== before) { + resolve('aborted'); + return; + } + if (active || ledger.snapshot().blocker) { + resolve('error'); + return; + } + try { + const previous = publication.getSnapshot(); + const decision = assertResumeEligible( + previous.decision, + previous.run, + pause, + Date.now() + ); + const id = crypto.randomUUID(); + admit( + Object.freeze({ + ...previous, + decision: claimDecision(decision, captured, id), + status: 'running', + subagents: Object.freeze([]), + run: Object.freeze({ id }), + }), + id, + signal, + resolve + ); + } catch { + resolve('error'); + } + }); + }), + }; +} diff --git a/scripts/react-parity/ag-ui-candidate-package.mjs b/scripts/react-parity/ag-ui-candidate-package.mjs index b6a6612a1..cb06cfdc7 100644 --- a/scripts/react-parity/ag-ui-candidate-package.mjs +++ b/scripts/react-parity/ag-ui-candidate-package.mjs @@ -24,6 +24,37 @@ import { installFootprint, runConsumer } from './verify-packages.mjs'; export const entry = 'fixtures/react-parity/ag-ui-candidate/entry'; export const factory = 'libs/ag-ui/src/runtime/create-session'; export const projector = 'libs/ag-ui/src/runtime/text-transcript'; +export const clientTools = 'libs/ag-ui/src/runtime/client-tools'; +export const reviewedSources = [ + entry, + ...['apply-patch', 'copy-data'].map( + (name) => 'libs/ag-ui/src/lib/internal/' + name + ), + ...[ + 'activity-messages', + 'client-tool-ledger', + 'client-tools', + 'create-http-request', + 'create-run', + 'create-session', + 'decision', + 'encrypted-messages', + 'interrupt-mode', + 'reconcile-transcript', + 'session-attribution', + 'session-observation', + 'session-publication', + 'state', + 'subagents', + 'submit-input', + 'text-messages', + 'text-transcript', + 'tool-messages', + 'transcript', + ].map((name) => 'libs/ag-ui/src/runtime/' + name), +] + .map((path) => path + '.ts') + .sort(); const bareImports = new Set(['@threadplane/core', '@ag-ui/client']); const seeds = ['@ag-ui/client']; export function candidateManifest(version, coreVersion, sdkVersion) { @@ -70,9 +101,18 @@ function forwarding(source, declaration) { return false; for (const name of statement.exportClause.elements) { if (name.propertyName) return false; - const expected = ['createSession', 'Session', 'SessionOptions'].includes( - name.name.text - ) + const expected = [ + 'ClientToolSessionSnapshot', + 'ClientToolCall', + 'ClientToolBlocker', + ].includes(name.name.text) + ? clientTools + : [ + 'createSession', + 'Session', + 'SessionOptions', + 'ClientToolSession', + ].includes(name.name.text) ? factory : projector; if (statement.moduleSpecifier.text !== '../../../' + expected + '.js') @@ -80,9 +120,15 @@ function forwarding(source, declaration) { const typeOnly = statement.isTypeOnly || name.isTypeOnly; if ( typeOnly !== - ['Session', 'SessionOptions', 'TextTranscriptRow'].includes( - name.name.text - ) + [ + 'Session', + 'SessionOptions', + 'ClientToolSession', + 'ClientToolSessionSnapshot', + 'ClientToolCall', + 'ClientToolBlocker', + 'TextTranscriptRow', + ].includes(name.name.text) ) return false; names.push(name.name.text); @@ -96,6 +142,10 @@ function forwarding(source, declaration) { 'createSession', 'Session', 'SessionOptions', + 'ClientToolSession', + 'ClientToolSessionSnapshot', + 'ClientToolCall', + 'ClientToolBlocker', 'projectTextTranscript', 'TextTranscriptRow', ] @@ -156,7 +206,11 @@ export function candidateViolations(directory) { !( name === entry + '.js' || name === entry + '.d.ts' || - /^libs\/ag-ui\/src\/.*\.(?:js|d\.ts)$/.test(name) + reviewedSources.some( + (source) => + name === source.slice(0, -3) + '.js' || + name === source.slice(0, -3) + '.d.ts' + ) ) || /\.(?:spec|test|type-test)\./.test(name) || /\/testing\//.test(name) @@ -171,7 +225,25 @@ export function candidateViolations(directory) { ).importedFiles) { const specifier = imported.fileName; if (!specifier.startsWith('.')) { - if (!bareImports.has(specifier)) + const parsed = ts.createSourceFile( + name, + readFileSync(path, 'utf8'), + ts.ScriptTarget.Latest, + true + ); + const toolTypesOnly = + path.endsWith('.d.ts') && + specifier === '@threadplane/core/tools' && + parsed.statements + .filter( + (statement) => statement.moduleSpecifier?.text === specifier + ) + .every( + (statement) => + ts.isImportDeclaration(statement) && + statement.importClause?.isTypeOnly + ); + if (!bareImports.has(specifier) && !toolTypesOnly) errors.push(`Forbidden candidate import ${specifier}`); continue; } @@ -215,9 +287,14 @@ export function emitCandidate(root, directory) { const host = ts.createCompilerHost(options); host.resolveModuleNames = (names, containingFile) => names.map((name) => - name === '@threadplane/core' + name === '@threadplane/core' || name === '@threadplane/core/tools' ? { - resolvedFileName: join(core, 'src/index.d.ts'), + resolvedFileName: join( + core, + name.endsWith('/tools') + ? 'src/tools/index.d.ts' + : 'src/index.d.ts' + ), extension: ts.Extension.Dts, isExternalLibraryImport: true, } @@ -247,12 +324,10 @@ export function emitCandidate(root, directory) { sha256: sha256(readFileSync(file.fileName)), })) .sort((a, b) => a.path.localeCompare(b.path)); - assert.ok( - sources.every( - (file) => - file.path === entry + '.ts' || file.path.startsWith('libs/ag-ui/src/') - ), - 'Only actual AG-UI and the direct fixture entry are emitted' + assert.deepEqual( + sources.map((file) => file.path).sort(), + reviewedSources, + 'Only the reviewed actual AG-UI source set is emitted' ); assert.equal(program.emit().emitSkipped, false); const lock = JSON.parse( @@ -365,9 +440,15 @@ export function agUiVendorOverrides(vendors) { // override also applies to the SDK's original, different exact request. // Mixed React consumers require both AG-UI's Zod 3 and render's Zod 4. // Qualify only multi-version roots; keep the supported unique RxJS pin. - const pins = vendorOverrides(vendors, vendors.map(({ name, version }) => `${name}@${version}`)); + const pins = vendorOverrides( + vendors, + vendors.map(({ name, version }) => `${name}@${version}`) + ); return Object.fromEntries( - vendors.map(({ name, version }) => [counts.get(name) === 1 ? name : `${name}@${version}`, pins[`${name}@${version}`]]) + vendors.map(({ name, version }) => [ + counts.get(name) === 1 ? name : `${name}@${version}`, + pins[`${name}@${version}`], + ]) ); } export function consumerVendorGraph( diff --git a/scripts/react-parity/ag-ui-candidate-package.spec.mjs b/scripts/react-parity/ag-ui-candidate-package.spec.mjs index d6862fdbb..f517d9bfd 100644 --- a/scripts/react-parity/ag-ui-candidate-package.spec.mjs +++ b/scripts/react-parity/ag-ui-candidate-package.spec.mjs @@ -29,6 +29,51 @@ import { import { reviewCandidate } from './verify-ag-ui-candidate.mjs'; const entry = 'fixtures/react-parity/ag-ui-candidate/entry'; +test('Library CI bootstraps the canonical client-tools lock before its worker tests and installed candidate proof', () => { + const workflow = readFileSync( + fileURLToPath(new URL('../../.github/workflows/ci.yml', import.meta.url)), + 'utf8' + ); + const library = workflow + .split('\n library:\n')[1] + ?.split('\n angular-compatibility:\n')[0]; + assert.ok(library, 'Actual Library workflow job'); + const steps = library.split('\n - ').slice(1); + const setup = steps.findIndex( + (step) => + step.includes('uses: astral-sh/setup-uv@') && + step.includes("python-version: '3.12'") + ); + const bootstrap = steps.findIndex((step) => + step.startsWith('name: Prepare canonical AG-UI client-tools fixture\n') + ); + const tests = steps.findIndex((step) => + step.startsWith('name: Verify canonical AG-UI client-tools fixtures\n') + ); + const candidate = steps.findIndex((step) => + step.startsWith('name: Verify real AG-UI candidate package\n') + ); + assert.ok( + setup >= 0 && bootstrap > setup && tests > bootstrap && candidate > tests, + 'Python setup then frozen client-tools sync then scoped fixture checks then installed proof' + ); + assert.match( + library, + /THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT: \/tmp\/threadplane-agui-client-tools-native-fixture-env/ + ); + assert.match( + steps[bootstrap], + /UV_PYTHON=3\.12 UV_PROJECT_ENVIRONMENT="\$THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT" uv sync --frozen --no-install-project --project cockpit\/ag-ui\/client-tools\/python/ + ); + assert.match( + steps[tests], + /node --test scripts\/react-parity\/ag-ui-candidate-package\.spec\.mjs fixtures\/react-parity\/native-ag-ui-client-tools\/server\.spec\.mjs/ + ); + assert.match( + steps[candidate], + /run: node scripts\/react-parity\/verify-ag-ui-candidate\.mjs/ + ); +}); test('mixed AG-UI graph includes local content parser and rejects a missing or changed edge', () => { const local = { '@threadplane/content': { @@ -135,11 +180,15 @@ function fixture(t) { mkdirSync(join(source, 'libs/ag-ui/src/runtime'), { recursive: true }); writeFileSync( join(source, entry + '.ts'), - "export { createSession } from '../../../libs/ag-ui/src/runtime/create-session.js';\nexport type { Session, SessionOptions } from '../../../libs/ag-ui/src/runtime/create-session.js';\nexport { projectTextTranscript } from '../../../libs/ag-ui/src/runtime/text-transcript.js';\nexport type { TextTranscriptRow } from '../../../libs/ag-ui/src/runtime/text-transcript.js';\n" + "export { createSession } from '../../../libs/ag-ui/src/runtime/create-session.js';\nexport type { Session, SessionOptions, ClientToolSession } from '../../../libs/ag-ui/src/runtime/create-session.js';\nexport type { ClientToolSessionSnapshot, ClientToolCall, ClientToolBlocker } from '../../../libs/ag-ui/src/runtime/client-tools.js';\nexport { projectTextTranscript } from '../../../libs/ag-ui/src/runtime/text-transcript.js';\nexport type { TextTranscriptRow } from '../../../libs/ag-ui/src/runtime/text-transcript.js';\n" ); writeFileSync( join(source, 'libs/ag-ui/src/runtime/create-session.ts'), - 'export interface SessionOptions { threadId: string }\nexport interface Session { value: string }\nexport function createSession(options: SessionOptions): Session { return { value: options.threadId }; }' + 'export interface SessionOptions { threadId: string }\nexport interface Session { value: string }\nexport type ClientToolSession = Session;\nexport function createSession(options: SessionOptions): Session { return { value: options.threadId }; }' + ); + writeFileSync( + join(source, 'libs/ag-ui/src/runtime/client-tools.ts'), + 'export interface ClientToolSessionSnapshot {}\nexport interface ClientToolCall {}\nexport interface ClientToolBlocker {}' ); writeFileSync( join(source, 'libs/ag-ui/src/runtime/text-transcript.ts'), @@ -185,6 +234,73 @@ function fixture(t) { test('accepts an actual emitted direct forwarding graph', (t) => { assert.deepEqual(candidateViolations(fixture(t).output), []); }); +test('accepts type-only core tools but rejects runtime and unrelated subpaths', (t) => { + const { output } = fixture(t); + const path = join(output, 'libs/ag-ui/src/runtime/create-session.d.ts'); + writeFileSync( + path, + readFileSync(path, 'utf8') + + "\nimport type { CheckedTools } from '@threadplane/core/tools';\n" + ); + assert.deepEqual(candidateViolations(output), []); + writeFileSync( + join(output, 'libs/ag-ui/src/runtime/create-session.js'), + "import '@threadplane/core/tools';\n" + ); + assert.ok( + candidateViolations(output).some((error) => + error.includes('Forbidden candidate import') + ) + ); + writeFileSync( + path, + "import type { X } from '@threadplane/core/unrelated';\n" + ); + assert.ok( + candidateViolations(output).some((error) => + error.includes('core/unrelated') + ) + ); +}); +test('rejects wrong client-tool forwarding modules and extra runtime exports', (t) => { + const { output } = fixture(t); + const declaration = join(output, entry + '.d.ts'); + writeFileSync( + declaration, + readFileSync(declaration, 'utf8').replace( + 'client-tools.js', + 'create-session.js' + ) + ); + assert.ok( + candidateViolations(output).some((error) => + error.includes('direct forwarding') + ) + ); + const runtime = join(output, entry + '.js'); + writeFileSync( + runtime, + readFileSync(runtime, 'utf8') + + "\nexport { ClientToolCall } from '../../../libs/ag-ui/src/runtime/client-tools.js';\n" + ); + assert.ok( + candidateViolations(output).filter((error) => + error.includes('direct forwarding') + ).length >= 2 + ); +}); +test('rejects an unreviewed emitted module within the backend tree', (t) => { + const { output } = fixture(t); + writeFileSync( + join(output, 'libs/ag-ui/src/runtime/unrelated.js'), + 'export const unexpected = true;' + ); + assert.ok( + candidateViolations(output).some((error) => + error.includes('Unexpected candidate file') + ) + ); +}); for (const path of [ 'libs/ag-ui/src/runtime/create-session.js', 'libs/ag-ui/src/runtime/create-session.d.ts', @@ -316,12 +432,20 @@ test('AG-UI resolves and pins its nested RxJS/Zod graph independently of LangGra assert.equal(agUiVendorOverrides(vendors).zod, '3.25.76'); const mixed = agUiVendorOverrides([ ...vendors, - { name: '@json-render/core', version: '0.16.0', dependencies: { zod: '4.3.6' } }, + { + name: '@json-render/core', + version: '0.16.0', + dependencies: { zod: '4.3.6' }, + }, { name: 'zod', version: '4.3.6', dependencies: {} }, ]); assert.equal(mixed['zod@3.25.76'], '3.25.76'); assert.equal(mixed['zod@4.3.6'], '4.3.6'); - assert.equal(mixed.zod, undefined, 'Do not flatten incompatible schema versions'); + assert.equal( + mixed.zod, + undefined, + 'Do not flatten incompatible schema versions' + ); assert.equal(mixed['@ag-ui/client']['zod@3.25.76'], '3.25.76'); assert.equal(mixed['@json-render/core']['zod@4.3.6'], '4.3.6'); lock.packages['node_modules/@ag-ui/client/node_modules/zod'].version = diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index cab846afd..485851daf 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baselineHead": "54d9a356b42dbf83c3b6ae5ff8b255a46a8f8f9e", + "baselineHead": "d618a6fc26c4d36129b3f25ef2ea63dec10cb886", "sourceState": { "modified": [], "untracked": [] @@ -2148,7 +2148,7 @@ "id": "config:.github/workflows/ci.yml", "kind": "config", "path": ".github/workflows/ci.yml", - "sha256": "aaa0cda27d8b4074673bca49a24e5c42e81af7200d9e482c4f2e89a9f2a998bb" + "sha256": "545dbf75ff9bcb3a2c8d4035da22c377aa13c20fb025345573846baef2c19233" }, { "id": "config:.github/workflows/publish-middleware-npm.yml", @@ -11963,6 +11963,18 @@ "path": "libs/ag-ui/src/runtime/activity-messages.ts", "sha256": "0bf7b2198b9d7cf7f36fcdf9efeaa9cbcc68fe87916dd40eb6a18936e71a6eef" }, + { + "id": "source:libs/ag-ui/src/runtime/client-tool-ledger.ts", + "kind": "source", + "path": "libs/ag-ui/src/runtime/client-tool-ledger.ts", + "sha256": "8578a2c73d4dec64720c49df8dfc23338a9c107535f7b55b1a46ac7695ce7c27" + }, + { + "id": "source:libs/ag-ui/src/runtime/client-tools.ts", + "kind": "source", + "path": "libs/ag-ui/src/runtime/client-tools.ts", + "sha256": "d667209f2c36742d645efd01072342bc31755b51cc85a0b0dc74e07dd6540bd4" + }, { "id": "source:libs/ag-ui/src/runtime/create-http-request.ts", "kind": "source", @@ -11973,13 +11985,13 @@ "id": "source:libs/ag-ui/src/runtime/create-run.ts", "kind": "source", "path": "libs/ag-ui/src/runtime/create-run.ts", - "sha256": "ee7a3397587f3f469d8d6fc89c08fe30e35c9bf28082e7cc58e058e27295cd2b" + "sha256": "33808703aabc66b6cafd880916037cc0e3bd3d2844476d40bb92130ddad87a1a" }, { "id": "source:libs/ag-ui/src/runtime/create-session.ts", "kind": "source", "path": "libs/ag-ui/src/runtime/create-session.ts", - "sha256": "76d493ff7eccd6f27503aed42e0282dff8d94862166fb5276cce8efcf804ca98" + "sha256": "a4e1b2004de27cbccf3f775d1d5c00da0b85aa153a5ef21209a53f61ffcf4485" }, { "id": "source:libs/ag-ui/src/runtime/decision.ts", diff --git a/scripts/react-parity/dispositions.json b/scripts/react-parity/dispositions.json index 875bc3278..3b74faac8 100644 --- a/scripts/react-parity/dispositions.json +++ b/scripts/react-parity/dispositions.json @@ -8855,6 +8855,22 @@ "reason": "Private immutable activity snapshot and delta observation only; excluded from public exports and legacy Angular reachability.", "note": "Captures activity content, complete patches and metadata, checks selected message ambiguity, and preserves prior observations. Operational patch failure retains content/type while admitting independently owned metadata; ownership failures propagate. Actual held HTTP covers continuation, snapshot retention, egress exclusion and callback error closure. Full-history admission, routing, lifecycle, rendering, session integration and public parity remain separate migration work." }, + { + "id": "source:libs/ag-ui/src/runtime/client-tool-ledger.ts", + "taskIds": ["T12", "T13"], + "treatment": "internal", + "status": "in-progress", + "reason": "Private session-lifetime client-tool admission, result retention and exact native echo delivery evidence; excluded from public exports and legacy Angular reachability.", + "note": "Tracks request baselines and finalized root invocations, rejects unsupported, conflicting or consumed calls, preserves exact locally authored results and acknowledges only matching native echoes. Private runtime and installed canonical checks cover owned execution and continuation; durable Store or replay, hosted proof and public package or React migration remain separate work." + }, + { + "id": "source:libs/ag-ui/src/runtime/client-tools.ts", + "taskIds": ["T12", "T13"], + "treatment": "internal", + "status": "in-progress", + "reason": "Private typed optional client-tool catalog and immutable local execution projection for native AG-UI sessions; excluded from public exports and legacy Angular reachability.", + "note": "Captures authored function definitions and opaque metadata for request catalogs and exposes typed readonly call, blocker and command-outcome evidence. The no-catalog session contract remains unchanged. Local installed declarations and offline canonical proofs do not establish public API migration, hosted model behavior or React dogfood completion." + }, { "id": "source:libs/ag-ui/src/runtime/create-http-request.ts", "taskIds": ["T12", "T13"], diff --git a/scripts/react-parity/verify-ag-ui-candidate.mjs b/scripts/react-parity/verify-ag-ui-candidate.mjs index b56c95f05..0dc5b80fa 100644 --- a/scripts/react-parity/verify-ag-ui-candidate.mjs +++ b/scripts/react-parity/verify-ag-ui-candidate.mjs @@ -26,6 +26,7 @@ import { installCandidate, packCandidate, projector, + reviewedSources, rootRangeOverrides, } from './ag-ui-candidate-package.mjs'; import { @@ -42,6 +43,7 @@ import { import { createReviewServer } from '../../fixtures/react-parity/native-ag-ui/server.mjs'; import { verifyBrowser } from './native-ag-ui-browser.mjs'; import { sequence } from './review-native-ag-ui.mjs'; +import { verifyInstalledClientTools } from './verify-ag-ui-client-tools.mjs'; const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const json = (path) => JSON.parse(readFileSync(path, 'utf8')); @@ -58,7 +60,7 @@ function artifacts(consumer, expected) { ); return records; } -function typeProbes(root, consumer, mixed) { +export function typeProbes(root, consumer, mixed) { cpSync( join(root, 'fixtures/react-parity/ag-ui-candidate/installed-types.ts'), join(consumer, 'installed-types.ts') @@ -389,10 +391,9 @@ export async function verifyAgUiCandidate({ root = defaultRoot, retain } = {}) { try { const candidate = join(temporary, 'candidate'), emission = emitCandidate(root, candidate); - assert.equal( - emission.sources.length, - 21, - 'Twenty actual backend modules plus the direct forwarding entry' + assert.deepEqual( + emission.sources.map((source) => source.path).sort(), + reviewedSources ); const packed = packCandidate(candidate, temporary), tarballs = packLocalArtifacts(root, temporary, [ @@ -511,6 +512,12 @@ export async function verifyAgUiCandidate({ root = defaultRoot, retain } = {}) { ); const browser = await browserProof(root, consumer, installed), provider = providerProof(root, consumer, installed); + const clientTools = await verifyInstalledClientTools({ + root, + consumer: node, + artifacts: nodeArtifacts, + receiptDirectory: temporary, + }); const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); const scope = [ @@ -569,13 +576,14 @@ export async function verifyAgUiCandidate({ root = defaultRoot, retain } = {}) { ownerClient: ownerVersion(consumer), browser, provider, + clientTools, }; writeJson(join(temporary, 'provenance.json'), provenance); success = true; console.log( JSON.stringify({ mode: 'verified-ag-ui-candidate', - modules: 21, + modules: emission.sources.length, tarballSha256: packed.sha256, node: nodeResult, ownerClient: provenance.ownerClient, diff --git a/scripts/react-parity/verify-ag-ui-client-tools.mjs b/scripts/react-parity/verify-ag-ui-client-tools.mjs new file mode 100644 index 000000000..c9f6c4486 --- /dev/null +++ b/scripts/react-parity/verify-ag-ui-client-tools.mjs @@ -0,0 +1,403 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { + cpSync, + readFileSync, + writeFileSync, + mkdirSync, + mkdtempSync, + realpathSync, +} from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildSync } from 'esbuild'; +import { + emitCandidate, + packCandidate, + installCandidate, + assertInstalledInputs, + rootRangeOverrides, + reviewedSources, +} from './ag-ui-candidate-package.mjs'; +import { + localArtifactRecords, + sha256, +} from './langgraph-candidate-package.mjs'; +import { packLocalArtifacts } from './verify-packages.mjs'; +import { checkTypes } from './verify-langgraph-candidate.mjs'; +import { createClientToolsServer } from '../../fixtures/react-parity/native-ag-ui-client-tools/server.mjs'; + +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const fixture = 'fixtures/react-parity/native-ag-ui-client-tools'; +// Prerequisites (run at the actual repository root): +// UV_PYTHON=3.12 UV_PROJECT_ENVIRONMENT=/tmp/threadplane-agui-client-tools-native-fixture-env uv sync --frozen --no-install-project --project cockpit/ag-ui/client-tools/python +// NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,content,angular,react --skip-nx-cache --outputStyle=stream +// node --test fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs +// node scripts/react-parity/verify-ag-ui-client-tools.mjs +// The existing full candidate verifier additionally requires: +// npm ci --prefix deployments/ag-ui-mastra --no-audit --no-fund +function childProbe(consumer, options) { + return new Promise((yes, no) => { + const child = spawn( + process.execPath, + ['installed-probe.mjs', JSON.stringify(options)], + { cwd: consumer, stdio: ['ignore', 'pipe', 'pipe'] } + ); + let output = '', + errors = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 30000); + child.stdout.on('data', (chunk) => { + output += chunk; + if (output.length > 4_000_000) child.kill('SIGKILL'); + }); + child.stderr.on('data', (chunk) => { + errors += chunk; + if (errors.length > 2_000_000) child.kill('SIGKILL'); + }); + child.once('error', (error) => { + clearTimeout(timer); + no(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (code !== 0) + no(new Error(`Installed probe failed ${code}: ${errors}`)); + else { + try { + yes(JSON.parse(output)); + } catch (error) { + no(error); + } + } + }); + }); +} +/** Reusable installed proof; existing candidate verifier supplies its real consumer. */ +export async function verifyInstalledClientTools({ + root = defaultRoot, + consumer, + artifacts, + receiptDirectory, +}) { + cpSync( + join(root, fixture, 'installed-probe.mjs'), + join(consumer, 'installed-probe.mjs') + ); + const graph = buildSync({ + absWorkingDir: consumer, + entryPoints: ['installed-probe.mjs'], + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }); + assertInstalledInputs( + consumer, + Object.keys(graph.metafile.inputs), + artifacts + ); + const sourcePaths = [ + '.github/workflows/ci.yml', + 'fixtures/react-parity/ag-ui-candidate/README.md', + fixture + '/compiled_worker.py', + fixture + '/server.mjs', + fixture + '/server.spec.mjs', + fixture + '/installed-probe.mjs', + 'scripts/react-parity/verify-ag-ui-client-tools.mjs', + 'scripts/react-parity/verify-ag-ui-candidate.mjs', + 'scripts/react-parity/ag-ui-candidate-package.mjs', + 'scripts/react-parity/ag-ui-candidate-package.spec.mjs', + 'fixtures/react-parity/ag-ui-candidate/installed-types.ts', + 'fixtures/react-parity/ag-ui-candidate/entry.ts', + 'fixtures/react-parity/ag-ui-candidate/backend-binding.ts', + ]; + const sources = sourcePaths.map((path) => ({ + path: join(root, path), + sha256: sha256(readFileSync(join(root, path))), + })); + const results = []; + for (const [mode, scenario, holdTerminal] of [ + ['nonstream', 'positive', false], + ['stream', 'positive', false], + ['unknown', 'unknown', false], + ['nonstream', 'held-cancel', true], + ['nonstream', 'handler-cancel', false], + ]) { + const server = await createClientToolsServer({ root, mode, holdTerminal }); + let result, failure; + try { + result = await childProbe(consumer, { url: server.url, scenario }); + assert.ok( + result.installedEntry.startsWith( + join(realpathSync(consumer), 'node_modules/@threadplane/ag-ui/') + ) + ); + const stats = server.stats(); + assert.deepEqual( + result.requests, + stats.requests.map((record) => record.body) + ); + assert.equal(stats.errors.length, 0); + for (const record of stats.requests) + assert.deepEqual(record.observations.socketAttempts, []); + if (scenario === 'positive') { + assert.equal(stats.requests.length, 2); + const [first, second] = stats.requests; + for (const record of stats.requests) { + const terminal = record.events.at(-1); + assert.equal(terminal.type, 'RUN_FINISHED'); + assert.equal(terminal.runId, record.body.runId); + assert.equal(terminal.threadId, record.body.threadId); + assert.equal(terminal.outcome, undefined); + } + const firstSnapshot = first.events + .filter((event) => event.type === 'MESSAGES_SNAPSHOT') + .at(-1); + const call = firstSnapshot.messages.find( + (row) => row.role === 'assistant' + ).toolCalls[0]; + assert.equal(call.id, 'scripted-weather-call-id'); + assert.deepEqual(JSON.parse(call.function.arguments), { + location: 'KASE', + }); + assert.equal( + first.events.some((event) => event.type === 'TOOL_CALL_END'), + mode === 'stream' + ); + const resultMessage = second.body.messages.find( + (row) => row.role === 'tool' + ); + assert.deepEqual( + second.body.messages, + [...firstSnapshot.messages, resultMessage], + 'Continuation carries the complete actual native history and exact authored result' + ); + const finalSnapshot = second.events + .filter((event) => event.type === 'MESSAGES_SNAPSHOT') + .at(-1); + const exactEcho = finalSnapshot.messages.some( + (row) => + row.id === resultMessage.id && + row.role === 'tool' && + row.toolCallId === resultMessage.toolCallId && + row.content === resultMessage.content + ); + assert.equal( + result.snapshot.clientTools.calls[0].delivery, + exactEcho ? 'observed' : 'sent-unconfirmed' + ); + const observations = second.observations; + for (const key of [ + 'workerPid', + 'graphIdentity', + 'checkpointerIdentity', + 'modelIdentity', + ]) + assert.equal( + first.observations[key], + observations[key], + 'Two requests share the one owned compiled graph/checkpointer/model worker' + ); + assert.equal(observations.workerPid, stats.ready.pid); + assert.equal(observations.modelCatalogs.length, 2); + assert.equal(observations.modelMessages.length, 2); + assert.deepEqual( + observations.modelCatalogs, + [first.body, second.body].map((body) => + body.tools.map((tool) => ({ type: 'function', function: tool })) + ), + 'The actual repository middleware binds each request catalog to the real model' + ); + assert.ok( + observations.modelMessages[1].some( + (row) => + row.type === 'tool' && + row.tool_call_id === resultMessage.toolCallId && + row.content === resultMessage.content + ) + ); + result.exactNativeEcho = exactEcho; + } + } catch (error) { + failure = String(error); + throw error; + } finally { + let cleanupFailure; + try { + await server.close(); + } catch (error) { + cleanupFailure = String(error); + } + const receipt = { + mode, + scenario, + terminalHold: holdTerminal + ? 'labelled-cancellation-boundary-only' + : false, + result, + failure, + cleanupFailure, + server: server.stats(), + }; + results.push(receipt); + writeFileSync( + join(receiptDirectory, `${mode}-${scenario}.json`), + JSON.stringify(receipt, null, 2) + '\n' + ); + if (cleanupFailure) throw new Error(cleanupFailure); + } + } + for (const source of sources) + assert.equal( + sha256(readFileSync(source.path)), + source.sha256, + 'Proof source bytes stayed frozen during execution' + ); + return { + results, + inputs: Object.keys(graph.metafile.inputs), + sources, + }; +} +export async function verifyClientTools({ root = defaultRoot } = {}) { + root = realpathSync(root); + const directory = mkdtempSync( + '/tmp/threadplane-agui-client-tools-installed-' + ); + let failure; + try { + const head = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + const unchanged = [ + 'package.json', + 'package-lock.json', + 'libs/ag-ui/package.json', + 'libs/ag-ui/src/index.ts', + 'libs/core/src', + 'libs/langgraph/src/runtime', + 'cockpit/ag-ui/client-tools/python', + 'packages/threadplane-middleware/src', + ]; + assert.equal( + execFileSync('git', ['diff', 'HEAD', '--name-only', '--', ...unchanged], { + cwd: root, + encoding: 'utf8', + }).trim(), + '', + 'Public package roots, canonical inputs and unrelated owners remain unchanged' + ); + const emission = emitCandidate(root, join(directory, 'candidate')); + assert.deepEqual( + emission.sources.map((source) => source.path).sort(), + reviewedSources + ); + const packed = packCandidate(join(directory, 'candidate'), directory); + const tarballs = packLocalArtifacts(root, directory, ['core']); + tarballs['@threadplane/ag-ui'] = 'file:' + packed.tarball; + const artifacts = Object.fromEntries( + Object.entries(localArtifactRecords(tarballs)).map(([name, record]) => [ + name, + record.files, + ]) + ); + const consumer = join(directory, 'consumer'); + mkdirSync(consumer); + const installation = installCandidate( + consumer, + { private: true, type: 'module' }, + tarballs, + JSON.parse(readFileSync(join(root, 'package-lock.json'))), + false, + rootRangeOverrides(root) + ); + cpSync( + join(root, 'fixtures/react-parity/ag-ui-candidate/installed-types.ts'), + join(consumer, 'installed-types.ts') + ); + const types = {}; + for (const mode of ['NodeNext', 'Bundler']) { + const config = `tsconfig.${mode}.json`; + writeFileSync( + join(consumer, config), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: mode === 'NodeNext' ? 'NodeNext' : 'ESNext', + moduleResolution: mode, + strict: true, + skipLibCheck: false, + types: [], + lib: ['ES2022', 'DOM'], + noEmit: true, + }, + files: ['installed-types.ts'], + }) + ); + types[mode] = checkTypes(consumer, config); + assert.ok( + types[mode].some( + (file) => + file.path === 'node_modules/@threadplane/core/src/tools/index.d.ts' + ), + 'Actual packed core tools declarations' + ); + } + for (const files of Object.values(types)) + assertInstalledInputs( + consumer, + files.map((file) => file.path), + artifacts + ); + const proof = await verifyInstalledClientTools({ + root, + consumer, + artifacts, + receiptDirectory: directory, + }); + const receipt = { + directory, + head, + unchanged, + emission, + packed, + installation, + types, + proof, + }; + writeFileSync( + join(directory, 'provenance.json'), + JSON.stringify(receipt, null, 2) + '\n' + ); + console.log( + JSON.stringify({ + directory, + scenarios: proof.results.map(({ mode, scenario, result }) => ({ + mode, + scenario, + delivery: result.snapshot.clientTools.calls[0]?.delivery, + })), + }) + ); + return receipt; + } catch (error) { + failure = String(error); + writeFileSync( + join(directory, 'failure.json'), + JSON.stringify( + { directory, failure, fixtureClosure: error.fixtureClosure }, + null, + 2 + ) + '\n' + ); + throw error; + } +} +if (process.argv[1] === fileURLToPath(import.meta.url)) + await verifyClientTools();