diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 832665b63..4c7c76a84 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -7461,6 +7461,38 @@ ], "examples": [] }, + { + "name": "SelectPendingClientToolCallsInput", + "kind": "interface", + "description": "Inputs for selectPendingClientToolCalls.", + "properties": [ + { + "name": "catalogNames", + "type": "ReadonlySet", + "description": "Client-declared tool names that should be handled in the browser.", + "optional": false + }, + { + "name": "isLoading", + "type": "boolean", + "description": "Whether the agent is currently streaming a run. Pending client tools are hidden while loading.", + "optional": false + }, + { + "name": "resolvedIds", + "type": "ReadonlySet", + "description": "Tool-call ids already resolved by the local client instance.", + "optional": false + }, + { + "name": "toolCalls", + "type": "readonly ToolCall[]", + "description": "Tool calls observed from the current agent state.", + "optional": false + } + ], + "examples": [] + }, { "name": "StandardSchemaV1", "kind": "interface", @@ -8895,6 +8927,25 @@ }, "examples": [] }, + { + "name": "selectPendingClientToolCalls", + "kind": "function", + "description": "Select client tool calls that are ready for browser-side resolution.", + "signature": "selectPendingClientToolCalls(input: SelectPendingClientToolCallsInput): readonly ToolCall[]", + "params": [ + { + "name": "input", + "type": "SelectPendingClientToolCallsInput", + "description": "", + "optional": false + } + ], + "returns": { + "type": "readonly ToolCall[]", + "description": "" + }, + "examples": [] + }, { "name": "startClientToolExecutor", "kind": "function", diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m1-pending-predicate-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m1-pending-predicate-plan.md new file mode 100644 index 000000000..4e44e7c60 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-client-tools-m1-pending-predicate-plan.md @@ -0,0 +1,172 @@ +# Client Tools M1 Pending Predicate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the duplicated client-tool `pending` predicate into a pure shared `@threadplane/chat` helper without changing adapter behavior. + +**Architecture:** Add one pure function in chat's client-tools package that receives plain inputs (`isLoading`, `toolCalls`, `catalogNames`, `resolvedIds`) and returns the same filtered tool-call list both adapters compute today. AG-UI and LangGraph keep their own signals, result application, and transport-specific continuation logic. + +**Tech Stack:** TypeScript, Angular computed signals, Vitest, Nx, existing `@threadplane/chat` public API generation. + +--- + +## Scope Guard + +M1 is spec §6 only. Do not add `settle()`, batching, abort signals, durable stores, max-turn guards, or continuation policy changes. Adapter behavior must remain byte-for-byte equivalent at the observable level: `pending()` is still empty while loading, excludes non-catalog calls, excludes calls with `result !== undefined`, excludes locally resolved ids, and keeps current resolve semantics. + +Because `selectPendingClientToolCalls` is exported from `@threadplane/chat`, run `npm run generate-api-docs` and include the generated API docs diff. + +## File Structure + +- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts` + - Owns the pure predicate and its input interface. +- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts` + - Covers loading, catalog filtering, result filtering, resolved-id filtering, multiple matches, and input immutability expectations. +- Modify: `libs/chat/src/lib/client-tools/index.ts` + - Export the helper and input type. +- Modify: `libs/chat/src/public-api.ts` + - Re-export the helper from the package public API. +- Modify: `libs/ag-ui/src/lib/client-tools.ts` + - Import and use the helper inside the existing `computed`, leaving `catalog` and `resolvedIds` signals local. +- Modify: `libs/langgraph/src/lib/client-tools.ts` + - Import and use the helper inside the existing `computed`, leaving `catalog`, `resolvedIds`, and `applyClientResult` local. +- Generated: website API docs touched by `npm run generate-api-docs`. + +## Task 1: Shared Predicate + +**Files:** +- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts` +- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts` +- Modify: `libs/chat/src/lib/client-tools/index.ts` +- Modify: `libs/chat/src/public-api.ts` + +- [x] **Step 1: Write the failing shared predicate tests** + +Tests should import `selectPendingClientToolCalls` from `./select-pending-client-tool-calls` and assert: + +```ts +expect(selectPendingClientToolCalls({ + isLoading: true, + toolCalls: [{ id: 'c1', name: 'get_weather', args: {}, status: 'complete' }], + catalogNames: new Set(['get_weather']), + resolvedIds: new Set(), +})).toEqual([]); +``` + +Also cover catalog mismatch, existing `result`, local `resolvedIds`, multiple included calls, and stable reference behavior for matching tool-call objects. + +- [x] **Step 2: Run the red test** + +Run: `npx vitest run src/lib/client-tools/select-pending-client-tool-calls.spec.ts --config vite.config.mts` from `libs/chat`. + +Expected: fail because the helper file does not exist. + +- [x] **Step 3: Implement the pure helper** + +Create: + +```ts +export interface SelectPendingClientToolCallsInput { + isLoading: boolean; + toolCalls: readonly ToolCall[]; + catalogNames: ReadonlySet; + resolvedIds: ReadonlySet; +} + +export function selectPendingClientToolCalls( + input: SelectPendingClientToolCallsInput, +): readonly ToolCall[] { + if (input.isLoading) return []; + return input.toolCalls.filter( + (tc) => + input.catalogNames.has(tc.name) && + tc.result === undefined && + !input.resolvedIds.has(tc.id), + ); +} +``` + +- [x] **Step 4: Export the helper** + +Export from `libs/chat/src/lib/client-tools/index.ts` and `libs/chat/src/public-api.ts`. + +- [x] **Step 5: Run the shared predicate tests** + +Run: `npx vitest run src/lib/client-tools/select-pending-client-tool-calls.spec.ts --config vite.config.mts` from `libs/chat`. + +Expected: pass. + +## Task 2: Adapter Wiring + +**Files:** +- Modify: `libs/ag-ui/src/lib/client-tools.ts` +- Modify: `libs/langgraph/src/lib/client-tools.ts` +- Test: `libs/ag-ui/src/lib/client-tools.spec.ts` +- Test: `libs/langgraph/src/lib/client-tools.spec.ts` + +- [x] **Step 1: Replace AG-UI inline predicate** + +Inside `pending: computed(() => ...)`, keep building local `catalogNames` and reading local `resolvedIds`, then return `selectPendingClientToolCalls({ isLoading: store.isLoading(), toolCalls: store.toolCalls(), catalogNames, resolvedIds: done })`. + +- [x] **Step 2: Run AG-UI focused tests** + +Run: `npx vitest run src/lib/client-tools.spec.ts --config vite.config.mts` from `libs/ag-ui`. + +Expected: pass with unchanged behavior. + +- [x] **Step 3: Replace LangGraph inline predicate** + +Inside `const pending = computed(...)`, keep building local `catalogNames` and reading local `resolvedIds`, then return the shared helper. + +- [x] **Step 4: Run LangGraph focused tests** + +Run: `npx vitest run src/lib/client-tools.spec.ts --config vite.config.mts` from `libs/langgraph`. + +Expected: pass with unchanged behavior. + +## Task 3: Docs and Verification + +**Files:** +- Generated API docs under `apps/website/content/docs/**` as produced by the repo generator. + +- [x] **Step 1: Regenerate API docs** + +Run: `npm run generate-api-docs`. + +Expected: generated docs include `selectPendingClientToolCalls` and `SelectPendingClientToolCallsInput`. + +- [x] **Step 2: Run project tests** + +Run: + +```bash +npx nx test chat +npx nx test ag-ui +npx nx test langgraph +``` + +Expected: all pass. + +- [x] **Step 3: Run lint and count errors** + +Run: + +```bash +npx nx lint chat 2>&1 | tee /tmp/threadplane-chat-m1-lint.log; grep -cE ' error ' /tmp/threadplane-chat-m1-lint.log +npx nx lint ag-ui 2>&1 | tee /tmp/threadplane-ag-ui-m1-lint.log; grep -cE ' error ' /tmp/threadplane-ag-ui-m1-lint.log +npx nx lint langgraph 2>&1 | tee /tmp/threadplane-langgraph-m1-lint.log; grep -cE ' error ' /tmp/threadplane-langgraph-m1-lint.log +``` + +Expected: each grep count is `0`. + +- [x] **Step 4: Diff audit** + +Run: + +```bash +git diff --check +git diff --name-only +rg -n "hashbrown|copilotkit|chatgpt|claude" libs docs apps/website/content/docs || true +``` + +Expected: no whitespace errors; changed files match M1 scope; forbidden external names are absent from code and generated docs except already-existing design/plan markdown where allowed. diff --git a/libs/ag-ui/src/lib/client-tools.ts b/libs/ag-ui/src/lib/client-tools.ts index 8ac7f16cd..5e18ea1f7 100644 --- a/libs/ag-ui/src/lib/client-tools.ts +++ b/libs/ag-ui/src/lib/client-tools.ts @@ -2,7 +2,12 @@ import { computed, signal } from '@angular/core'; import type { AbstractAgent } from '@ag-ui/client'; import type { Tool, Message } from '@ag-ui/core'; -import type { ClientToolsCapability, ClientToolResult, ClientToolSpec } from '@threadplane/chat'; +import { + selectPendingClientToolCalls, + type ClientToolsCapability, + type ClientToolResult, + type ClientToolSpec, +} from '@threadplane/chat'; import type { ReducerStore } from './reducer'; /** @@ -63,12 +68,12 @@ export function createClientToolsCapability( pending: computed(() => { // Client tools are only actionable after the run ends (backend signals it // by ending the run WITHOUT emitting TOOL_CALL_RESULT for client tools). - if (store.isLoading()) return []; - const names = new Set(catalog().map((s) => s.name)); - const done = resolvedIds(); - return store.toolCalls().filter( - (tc) => names.has(tc.name) && tc.result === undefined && !done.has(tc.id), - ); + return selectPendingClientToolCalls({ + isLoading: store.isLoading(), + toolCalls: store.toolCalls(), + catalogNames: new Set(catalog().map((s) => s.name)), + resolvedIds: resolvedIds(), + }); }), resolve(id: string, result: ClientToolResult): void { diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 05b46764b..57436b5b3 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -5,6 +5,8 @@ export type { ClientToolDef, FunctionToolDef, AnyFunctionToolDef, ViewToolDef, A export type { ClientToolSpec } from './to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; export { validateArgs, executeFunctionTool } from './execute'; +export { selectPendingClientToolCalls } from './select-pending-client-tool-calls'; +export type { SelectPendingClientToolCallsInput } from './select-pending-client-tool-calls'; export { startClientToolExecutor } from './client-tool-executor'; export { createClientToolsCoordinator, toClientToolSpecs } from './client-tools-coordinator'; export type { ClientToolsCoordinator } from './client-tools-coordinator'; diff --git a/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts b/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts new file mode 100644 index 000000000..4c6bd52a1 --- /dev/null +++ b/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import type { ToolCall } from '../agent/tool-call'; +import { selectPendingClientToolCalls } from './select-pending-client-tool-calls'; + +const weatherCall: ToolCall = { + id: 'c1', + name: 'get_weather', + args: {}, + status: 'complete', +}; + +const stockCall: ToolCall = { + id: 'c2', + name: 'get_stock_price', + args: {}, + status: 'complete', +}; + +function select(overrides: Partial[0]> = {}) { + return selectPendingClientToolCalls({ + isLoading: false, + toolCalls: [weatherCall], + catalogNames: new Set(['get_weather']), + resolvedIds: new Set(), + ...overrides, + }); +} + +describe('selectPendingClientToolCalls', () => { + it('returns [] while the agent is loading', () => { + expect(select({ isLoading: true })).toEqual([]); + }); + + it('includes catalog tool calls with no result that were not resolved locally', () => { + const pending = select(); + + expect(pending).toEqual([weatherCall]); + expect(pending[0]).toBe(weatherCall); + }); + + it('excludes tool calls whose name is not in the catalog', () => { + expect(select({ catalogNames: new Set(['other_tool']) })).toEqual([]); + }); + + it('excludes tool calls that already have a server result', () => { + expect(select({ + toolCalls: [{ ...weatherCall, result: { temp: 72 } }], + })).toEqual([]); + }); + + it('excludes tool calls that were resolved locally', () => { + expect(select({ resolvedIds: new Set(['c1']) })).toEqual([]); + }); + + it('returns multiple matching calls in source order', () => { + expect(select({ + toolCalls: [weatherCall, stockCall], + catalogNames: new Set(['get_weather', 'get_stock_price']), + })).toEqual([weatherCall, stockCall]); + }); +}); diff --git a/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts b/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts new file mode 100644 index 000000000..fe6dd1526 --- /dev/null +++ b/libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +import type { ToolCall } from '../agent/tool-call'; + +/** Inputs for {@link selectPendingClientToolCalls}. */ +export interface SelectPendingClientToolCallsInput { + /** Whether the agent is currently streaming a run. Pending client tools are hidden while loading. */ + isLoading: boolean; + /** Tool calls observed from the current agent state. */ + toolCalls: readonly ToolCall[]; + /** Client-declared tool names that should be handled in the browser. */ + catalogNames: ReadonlySet; + /** Tool-call ids already resolved by the local client instance. */ + resolvedIds: ReadonlySet; +} + +/** Select client tool calls that are ready for browser-side resolution. */ +export function selectPendingClientToolCalls( + input: SelectPendingClientToolCallsInput, +): readonly ToolCall[] { + if (input.isLoading) return []; + return input.toolCalls.filter( + (tc) => + input.catalogNames.has(tc.name) && + tc.result === undefined && + !input.resolvedIds.has(tc.id), + ); +} diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index c3048c6e8..8f8596e8f 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -236,6 +236,8 @@ export type { ViewProps } from './lib/client-tools/component-inputs'; export type ToolArgs = import('./lib/client-tools/tool-def').StandardSchemaInferOutput; export type { ClientToolSpec } from './lib/client-tools/to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './lib/client-tools/client-tools-capability'; +export { selectPendingClientToolCalls } from './lib/client-tools/select-pending-client-tool-calls'; +export type { SelectPendingClientToolCallsInput } from './lib/client-tools/select-pending-client-tool-calls'; export { validateArgs, executeFunctionTool } from './lib/client-tools/execute'; export { startClientToolExecutor } from './lib/client-tools/client-tool-executor'; // createClientToolsCoordinator: internal — provideChat wires it; not public. diff --git a/libs/langgraph/src/lib/client-tools.ts b/libs/langgraph/src/lib/client-tools.ts index 5f339484f..c90b78190 100644 --- a/libs/langgraph/src/lib/client-tools.ts +++ b/libs/langgraph/src/lib/client-tools.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MIT import { computed, signal } from '@angular/core'; import type { Signal } from '@angular/core'; -import type { ClientToolsCapability, ClientToolResult, ClientToolSpec } from '@threadplane/chat'; +import { + selectPendingClientToolCalls, + type ClientToolsCapability, + type ClientToolResult, + type ClientToolSpec, +} from '@threadplane/chat'; import type { ToolCall } from '@threadplane/chat'; import type { LangGraphSubmitOptions } from './agent.types'; @@ -109,12 +114,12 @@ export function createClientToolsCapability( // Client tools are only actionable after the run ends (the backend // signals this by ending the run WITHOUT emitting a ToolMessage result // for client tools). - if (store.isLoading()) return []; - const names = new Set(catalog().map((s) => s.name)); - const done = resolvedIds(); - return store.toolCalls().filter( - (tc) => names.has(tc.name) && tc.result === undefined && !done.has(tc.id), - ); + return selectPendingClientToolCalls({ + isLoading: store.isLoading(), + toolCalls: store.toolCalls(), + catalogNames: new Set(catalog().map((s) => s.name)), + resolvedIds: resolvedIds(), + }); }); const capability: ClientToolsCapability & { catalog: Signal } = {