Skip to content

Commit 241a05f

Browse files
committed
refactor: share client tool pending predicate
1 parent 17f221e commit 241a05f

8 files changed

Lines changed: 340 additions & 14 deletions

File tree

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7461,6 +7461,38 @@
74617461
],
74627462
"examples": []
74637463
},
7464+
{
7465+
"name": "SelectPendingClientToolCallsInput",
7466+
"kind": "interface",
7467+
"description": "Inputs for selectPendingClientToolCalls.",
7468+
"properties": [
7469+
{
7470+
"name": "catalogNames",
7471+
"type": "ReadonlySet<string>",
7472+
"description": "Client-declared tool names that should be handled in the browser.",
7473+
"optional": false
7474+
},
7475+
{
7476+
"name": "isLoading",
7477+
"type": "boolean",
7478+
"description": "Whether the agent is currently streaming a run. Pending client tools are hidden while loading.",
7479+
"optional": false
7480+
},
7481+
{
7482+
"name": "resolvedIds",
7483+
"type": "ReadonlySet<string>",
7484+
"description": "Tool-call ids already resolved by the local client instance.",
7485+
"optional": false
7486+
},
7487+
{
7488+
"name": "toolCalls",
7489+
"type": "readonly ToolCall[]",
7490+
"description": "Tool calls observed from the current agent state.",
7491+
"optional": false
7492+
}
7493+
],
7494+
"examples": []
7495+
},
74647496
{
74657497
"name": "StandardSchemaV1",
74667498
"kind": "interface",
@@ -8895,6 +8927,25 @@
88958927
},
88968928
"examples": []
88978929
},
8930+
{
8931+
"name": "selectPendingClientToolCalls",
8932+
"kind": "function",
8933+
"description": "Select client tool calls that are ready for browser-side resolution.",
8934+
"signature": "selectPendingClientToolCalls(input: SelectPendingClientToolCallsInput): readonly ToolCall[]",
8935+
"params": [
8936+
{
8937+
"name": "input",
8938+
"type": "SelectPendingClientToolCallsInput",
8939+
"description": "",
8940+
"optional": false
8941+
}
8942+
],
8943+
"returns": {
8944+
"type": "readonly ToolCall[]",
8945+
"description": ""
8946+
},
8947+
"examples": []
8948+
},
88988949
{
88998950
"name": "startClientToolExecutor",
89008951
"kind": "function",
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Client Tools M1 Pending Predicate Implementation Plan
2+
3+
> **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.
4+
5+
**Goal:** Extract the duplicated client-tool `pending` predicate into a pure shared `@threadplane/chat` helper without changing adapter behavior.
6+
7+
**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.
8+
9+
**Tech Stack:** TypeScript, Angular computed signals, Vitest, Nx, existing `@threadplane/chat` public API generation.
10+
11+
---
12+
13+
## Scope Guard
14+
15+
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.
16+
17+
Because `selectPendingClientToolCalls` is exported from `@threadplane/chat`, run `npm run generate-api-docs` and include the generated API docs diff.
18+
19+
## File Structure
20+
21+
- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts`
22+
- Owns the pure predicate and its input interface.
23+
- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts`
24+
- Covers loading, catalog filtering, result filtering, resolved-id filtering, multiple matches, and input immutability expectations.
25+
- Modify: `libs/chat/src/lib/client-tools/index.ts`
26+
- Export the helper and input type.
27+
- Modify: `libs/chat/src/public-api.ts`
28+
- Re-export the helper from the package public API.
29+
- Modify: `libs/ag-ui/src/lib/client-tools.ts`
30+
- Import and use the helper inside the existing `computed`, leaving `catalog` and `resolvedIds` signals local.
31+
- Modify: `libs/langgraph/src/lib/client-tools.ts`
32+
- Import and use the helper inside the existing `computed`, leaving `catalog`, `resolvedIds`, and `applyClientResult` local.
33+
- Generated: website API docs touched by `npm run generate-api-docs`.
34+
35+
## Task 1: Shared Predicate
36+
37+
**Files:**
38+
- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.spec.ts`
39+
- Create: `libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts`
40+
- Modify: `libs/chat/src/lib/client-tools/index.ts`
41+
- Modify: `libs/chat/src/public-api.ts`
42+
43+
- [x] **Step 1: Write the failing shared predicate tests**
44+
45+
Tests should import `selectPendingClientToolCalls` from `./select-pending-client-tool-calls` and assert:
46+
47+
```ts
48+
expect(selectPendingClientToolCalls({
49+
isLoading: true,
50+
toolCalls: [{ id: 'c1', name: 'get_weather', args: {}, status: 'complete' }],
51+
catalogNames: new Set(['get_weather']),
52+
resolvedIds: new Set(),
53+
})).toEqual([]);
54+
```
55+
56+
Also cover catalog mismatch, existing `result`, local `resolvedIds`, multiple included calls, and stable reference behavior for matching tool-call objects.
57+
58+
- [x] **Step 2: Run the red test**
59+
60+
Run: `npx vitest run src/lib/client-tools/select-pending-client-tool-calls.spec.ts --config vite.config.mts` from `libs/chat`.
61+
62+
Expected: fail because the helper file does not exist.
63+
64+
- [x] **Step 3: Implement the pure helper**
65+
66+
Create:
67+
68+
```ts
69+
export interface SelectPendingClientToolCallsInput {
70+
isLoading: boolean;
71+
toolCalls: readonly ToolCall[];
72+
catalogNames: ReadonlySet<string>;
73+
resolvedIds: ReadonlySet<string>;
74+
}
75+
76+
export function selectPendingClientToolCalls(
77+
input: SelectPendingClientToolCallsInput,
78+
): readonly ToolCall[] {
79+
if (input.isLoading) return [];
80+
return input.toolCalls.filter(
81+
(tc) =>
82+
input.catalogNames.has(tc.name) &&
83+
tc.result === undefined &&
84+
!input.resolvedIds.has(tc.id),
85+
);
86+
}
87+
```
88+
89+
- [x] **Step 4: Export the helper**
90+
91+
Export from `libs/chat/src/lib/client-tools/index.ts` and `libs/chat/src/public-api.ts`.
92+
93+
- [x] **Step 5: Run the shared predicate tests**
94+
95+
Run: `npx vitest run src/lib/client-tools/select-pending-client-tool-calls.spec.ts --config vite.config.mts` from `libs/chat`.
96+
97+
Expected: pass.
98+
99+
## Task 2: Adapter Wiring
100+
101+
**Files:**
102+
- Modify: `libs/ag-ui/src/lib/client-tools.ts`
103+
- Modify: `libs/langgraph/src/lib/client-tools.ts`
104+
- Test: `libs/ag-ui/src/lib/client-tools.spec.ts`
105+
- Test: `libs/langgraph/src/lib/client-tools.spec.ts`
106+
107+
- [x] **Step 1: Replace AG-UI inline predicate**
108+
109+
Inside `pending: computed(() => ...)`, keep building local `catalogNames` and reading local `resolvedIds`, then return `selectPendingClientToolCalls({ isLoading: store.isLoading(), toolCalls: store.toolCalls(), catalogNames, resolvedIds: done })`.
110+
111+
- [x] **Step 2: Run AG-UI focused tests**
112+
113+
Run: `npx vitest run src/lib/client-tools.spec.ts --config vite.config.mts` from `libs/ag-ui`.
114+
115+
Expected: pass with unchanged behavior.
116+
117+
- [x] **Step 3: Replace LangGraph inline predicate**
118+
119+
Inside `const pending = computed(...)`, keep building local `catalogNames` and reading local `resolvedIds`, then return the shared helper.
120+
121+
- [x] **Step 4: Run LangGraph focused tests**
122+
123+
Run: `npx vitest run src/lib/client-tools.spec.ts --config vite.config.mts` from `libs/langgraph`.
124+
125+
Expected: pass with unchanged behavior.
126+
127+
## Task 3: Docs and Verification
128+
129+
**Files:**
130+
- Generated API docs under `apps/website/content/docs/**` as produced by the repo generator.
131+
132+
- [x] **Step 1: Regenerate API docs**
133+
134+
Run: `npm run generate-api-docs`.
135+
136+
Expected: generated docs include `selectPendingClientToolCalls` and `SelectPendingClientToolCallsInput`.
137+
138+
- [x] **Step 2: Run project tests**
139+
140+
Run:
141+
142+
```bash
143+
npx nx test chat
144+
npx nx test ag-ui
145+
npx nx test langgraph
146+
```
147+
148+
Expected: all pass.
149+
150+
- [x] **Step 3: Run lint and count errors**
151+
152+
Run:
153+
154+
```bash
155+
npx nx lint chat 2>&1 | tee /tmp/threadplane-chat-m1-lint.log; grep -cE ' error ' /tmp/threadplane-chat-m1-lint.log
156+
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
157+
npx nx lint langgraph 2>&1 | tee /tmp/threadplane-langgraph-m1-lint.log; grep -cE ' error ' /tmp/threadplane-langgraph-m1-lint.log
158+
```
159+
160+
Expected: each grep count is `0`.
161+
162+
- [x] **Step 4: Diff audit**
163+
164+
Run:
165+
166+
```bash
167+
git diff --check
168+
git diff --name-only
169+
rg -n "hashbrown|copilotkit|chatgpt|claude" libs docs apps/website/content/docs || true
170+
```
171+
172+
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.

libs/ag-ui/src/lib/client-tools.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
import { computed, signal } from '@angular/core';
33
import type { AbstractAgent } from '@ag-ui/client';
44
import type { Tool, Message } from '@ag-ui/core';
5-
import type { ClientToolsCapability, ClientToolResult, ClientToolSpec } from '@threadplane/chat';
5+
import {
6+
selectPendingClientToolCalls,
7+
type ClientToolsCapability,
8+
type ClientToolResult,
9+
type ClientToolSpec,
10+
} from '@threadplane/chat';
611
import type { ReducerStore } from './reducer';
712

813
/**
@@ -63,12 +68,12 @@ export function createClientToolsCapability(
6368
pending: computed(() => {
6469
// Client tools are only actionable after the run ends (backend signals it
6570
// by ending the run WITHOUT emitting TOOL_CALL_RESULT for client tools).
66-
if (store.isLoading()) return [];
67-
const names = new Set(catalog().map((s) => s.name));
68-
const done = resolvedIds();
69-
return store.toolCalls().filter(
70-
(tc) => names.has(tc.name) && tc.result === undefined && !done.has(tc.id),
71-
);
71+
return selectPendingClientToolCalls({
72+
isLoading: store.isLoading(),
73+
toolCalls: store.toolCalls(),
74+
catalogNames: new Set(catalog().map((s) => s.name)),
75+
resolvedIds: resolvedIds(),
76+
});
7277
}),
7378

7479
resolve(id: string, result: ClientToolResult): void {

libs/chat/src/lib/client-tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type { ClientToolDef, FunctionToolDef, AnyFunctionToolDef, ViewToolDef, A
55
export type { ClientToolSpec } from './to-json-schema';
66
export type { ClientToolsCapability, ClientToolResult } from './client-tools-capability';
77
export { validateArgs, executeFunctionTool } from './execute';
8+
export { selectPendingClientToolCalls } from './select-pending-client-tool-calls';
9+
export type { SelectPendingClientToolCallsInput } from './select-pending-client-tool-calls';
810
export { startClientToolExecutor } from './client-tool-executor';
911
export { createClientToolsCoordinator, toClientToolSpecs } from './client-tools-coordinator';
1012
export type { ClientToolsCoordinator } from './client-tools-coordinator';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, expect, it } from 'vitest';
3+
import type { ToolCall } from '../agent/tool-call';
4+
import { selectPendingClientToolCalls } from './select-pending-client-tool-calls';
5+
6+
const weatherCall: ToolCall = {
7+
id: 'c1',
8+
name: 'get_weather',
9+
args: {},
10+
status: 'complete',
11+
};
12+
13+
const stockCall: ToolCall = {
14+
id: 'c2',
15+
name: 'get_stock_price',
16+
args: {},
17+
status: 'complete',
18+
};
19+
20+
function select(overrides: Partial<Parameters<typeof selectPendingClientToolCalls>[0]> = {}) {
21+
return selectPendingClientToolCalls({
22+
isLoading: false,
23+
toolCalls: [weatherCall],
24+
catalogNames: new Set(['get_weather']),
25+
resolvedIds: new Set(),
26+
...overrides,
27+
});
28+
}
29+
30+
describe('selectPendingClientToolCalls', () => {
31+
it('returns [] while the agent is loading', () => {
32+
expect(select({ isLoading: true })).toEqual([]);
33+
});
34+
35+
it('includes catalog tool calls with no result that were not resolved locally', () => {
36+
const pending = select();
37+
38+
expect(pending).toEqual([weatherCall]);
39+
expect(pending[0]).toBe(weatherCall);
40+
});
41+
42+
it('excludes tool calls whose name is not in the catalog', () => {
43+
expect(select({ catalogNames: new Set(['other_tool']) })).toEqual([]);
44+
});
45+
46+
it('excludes tool calls that already have a server result', () => {
47+
expect(select({
48+
toolCalls: [{ ...weatherCall, result: { temp: 72 } }],
49+
})).toEqual([]);
50+
});
51+
52+
it('excludes tool calls that were resolved locally', () => {
53+
expect(select({ resolvedIds: new Set(['c1']) })).toEqual([]);
54+
});
55+
56+
it('returns multiple matching calls in source order', () => {
57+
expect(select({
58+
toolCalls: [weatherCall, stockCall],
59+
catalogNames: new Set(['get_weather', 'get_stock_price']),
60+
})).toEqual([weatherCall, stockCall]);
61+
});
62+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MIT
2+
import type { ToolCall } from '../agent/tool-call';
3+
4+
/** Inputs for {@link selectPendingClientToolCalls}. */
5+
export interface SelectPendingClientToolCallsInput {
6+
/** Whether the agent is currently streaming a run. Pending client tools are hidden while loading. */
7+
isLoading: boolean;
8+
/** Tool calls observed from the current agent state. */
9+
toolCalls: readonly ToolCall[];
10+
/** Client-declared tool names that should be handled in the browser. */
11+
catalogNames: ReadonlySet<string>;
12+
/** Tool-call ids already resolved by the local client instance. */
13+
resolvedIds: ReadonlySet<string>;
14+
}
15+
16+
/** Select client tool calls that are ready for browser-side resolution. */
17+
export function selectPendingClientToolCalls(
18+
input: SelectPendingClientToolCallsInput,
19+
): readonly ToolCall[] {
20+
if (input.isLoading) return [];
21+
return input.toolCalls.filter(
22+
(tc) =>
23+
input.catalogNames.has(tc.name) &&
24+
tc.result === undefined &&
25+
!input.resolvedIds.has(tc.id),
26+
);
27+
}

libs/chat/src/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ export type { ViewProps } from './lib/client-tools/component-inputs';
236236
export type ToolArgs<S extends import('./lib/client-tools/tool-def').StandardSchemaV1> = import('./lib/client-tools/tool-def').StandardSchemaInferOutput<S>;
237237
export type { ClientToolSpec } from './lib/client-tools/to-json-schema';
238238
export type { ClientToolsCapability, ClientToolResult } from './lib/client-tools/client-tools-capability';
239+
export { selectPendingClientToolCalls } from './lib/client-tools/select-pending-client-tool-calls';
240+
export type { SelectPendingClientToolCallsInput } from './lib/client-tools/select-pending-client-tool-calls';
239241
export { validateArgs, executeFunctionTool } from './lib/client-tools/execute';
240242
export { startClientToolExecutor } from './lib/client-tools/client-tool-executor';
241243
// createClientToolsCoordinator: internal — provideChat wires it; not public.

0 commit comments

Comments
 (0)