Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -7461,6 +7461,38 @@
],
"examples": []
},
{
"name": "SelectPendingClientToolCallsInput",
"kind": "interface",
"description": "Inputs for selectPendingClientToolCalls.",
"properties": [
{
"name": "catalogNames",
"type": "ReadonlySet<string>",
"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<string>",
"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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
resolvedIds: ReadonlySet<string>;
}

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.
19 changes: 12 additions & 7 deletions libs/ag-ui/src/lib/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions libs/chat/src/lib/client-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof selectPendingClientToolCalls>[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]);
});
});
27 changes: 27 additions & 0 deletions libs/chat/src/lib/client-tools/select-pending-client-tool-calls.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
/** Tool-call ids already resolved by the local client instance. */
resolvedIds: ReadonlySet<string>;
}

/** 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),
);
}
2 changes: 2 additions & 0 deletions libs/chat/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export type { ViewProps } from './lib/client-tools/component-inputs';
export type ToolArgs<S extends import('./lib/client-tools/tool-def').StandardSchemaV1> = import('./lib/client-tools/tool-def').StandardSchemaInferOutput<S>;
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.
Expand Down
Loading
Loading