From fe4aa8f91376146db6cd63a7c7a5f86f46c10f46 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 16:36:00 -0700 Subject: [PATCH 1/2] feat: add signal resource agent proof --- ...07-08-client-tools-m6-agent-bridge-plan.md | 71 +++-- .../agent/signal-chat-resource-agent.spec.ts | 246 ++++++++++++++++++ .../lib/agent/signal-chat-resource-agent.ts | 157 +++++++++++ 3 files changed, 459 insertions(+), 15 deletions(-) create mode 100644 libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts create mode 100644 libs/chat/src/lib/agent/signal-chat-resource-agent.ts diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m6-agent-bridge-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m6-agent-bridge-plan.md index e71759d13..9bb086891 100644 --- a/docs/superpowers/plans/2026-07-08-client-tools-m6-agent-bridge-plan.md +++ b/docs/superpowers/plans/2026-07-08-client-tools-m6-agent-bridge-plan.md @@ -112,7 +112,7 @@ Discovery result: the inspected provider exposes a stateful chat/resource API pl **Files:** - Modify this plan or add contract notes if the decision is made before implementation. -- [ ] **Step 1: Choose package boundary** +- [x] **Step 1: Choose package boundary** Pick one: @@ -120,7 +120,9 @@ Pick one: - private/internal adapter, if this is only a migration bridge; - no code, if provider APIs are unstable or unavailable. -- [ ] **Step 2: Decide public API shape** +Decision: add a private/internal adapter proof under `libs/chat` first. Do not create a new published package or public export until the package boundary is approved. + +- [x] **Step 2: Decide public API shape** If public, mirror existing adapter ergonomics: @@ -132,7 +134,9 @@ injectAgent(ref?) Use a provider-specific prefix internally if needed to avoid collisions with existing adapter types, but keep the consumer mental model aligned with AG-UI and LangGraph. -- [ ] **Step 3: Decide client-tools mapping** +Decision: no public API in the adapter-proof PR. A future public package should mirror `toAgent(source, options?)` and DI provider ergonomics only after API shape review. + +- [x] **Step 3: Decide client-tools mapping** Confirm whether provider tools are: @@ -142,13 +146,15 @@ Confirm whether provider tools are: Only map browser-executed tools into Threadplane `action`/`view`/`ask` when the provider supplies stable pending-call identifiers and a result-return API. +Decision: do not map provider tools into `ClientToolsCapability` in the adapter-proof PR. The inspected source exposes provider-owned tool execution without a stable browser result-return API for Threadplane client tools. + ## Task 3: Contract Tests First **Files:** - Add: adapter conformance spec path TBD. - Add: provider fake/test fixture path TBD. -- [ ] **Step 1: Build a scriptable provider fake** +- [x] **Step 1: Build a scriptable provider fake** Create a test-only fake from the captured event contract. It must support: @@ -157,7 +163,9 @@ Create a test-only fake from the captured event contract. It must support: - message/tool history inspection; - branch-on-tool-result behavior if the provider supports continuation. -- [ ] **Step 2: Write failing `Agent` conformance tests** +Implementation note: the first adapter-proof PR uses a scriptable signal-resource fake scoped to the observed public resource surface, not transport-frame scripts. + +- [x] **Step 2: Write failing `Agent` conformance tests** Cover: @@ -171,7 +179,7 @@ Cover: - regenerate trims at the selected assistant message and reruns; - custom events flow through `events$` or adapter-specific signal if supported. -- [ ] **Step 3: Write client-tools bridge tests only if supported** +- [x] **Step 3: Write client-tools bridge tests only if supported** If provider has stable browser tool primitives, cover: @@ -184,16 +192,20 @@ If provider has stable browser tool primitives, cover: Do not implement client-tools mapping if the provider lacks stable tool-call IDs or result APIs. +Decision: not supported for this proof. The adapter tests cover tool-call projection only; no client-tools bridge tests are added. + ## Task 4: Minimal Adapter Implementation **Files:** - Add/modify only after Tasks 1-3 pass their decision gates. -- [ ] **Step 1: Implement event reduction** +- [x] **Step 1: Implement event reduction** Prefer the AG-UI reducer path if compatible. Otherwise implement only the reducer cases proven by fixtures. Unknown events must be ignored or surfaced as neutral custom events; they must not crash the stream. -- [ ] **Step 2: Implement actions** +Implementation note: the proof uses resource-state projection rather than event reduction because the inspected public API exposes resource state, not a public event subscriber. + +- [x] **Step 2: Implement actions** Map: @@ -204,11 +216,13 @@ Map: to provider APIs only where semantics match the existing `Agent` contract. Throwing is acceptable for programmer misuse, but normal unsupported provider features should be absent or no-op only if that matches existing adapter precedent. -- [ ] **Step 3: Add Angular provider wiring** +- [x] **Step 3: Add Angular provider wiring** If public or DI-supported, mirror the existing typed `AgentRef` overloads and factory config pattern from AG-UI/LangGraph. -- [ ] **Step 4: Add public exports and docs only after approval** +Decision: not applicable to the private proof. No DI/provider helper is added. + +- [x] **Step 4: Add public exports and docs only after approval** If the adapter is public: @@ -218,16 +232,24 @@ npm run generate-api-docs Expected: generated API docs include the new bridge types. Commit generated docs with the public export. +Decision: not applicable to the private proof. No public export is added, so API docs are unchanged. + ## Task 5: Verification **Files:** - No additional files. -- [ ] **Step 1: Run focused adapter tests** +- [x] **Step 1: Run focused adapter tests** Run the new adapter test target or focused Vitest spec. -- [ ] **Step 2: Run affected existing adapter/chat tests** +Verified: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/agent/signal-chat-resource-agent.spec.ts +``` + +- [x] **Step 2: Run affected existing adapter/chat tests** Run: @@ -239,18 +261,37 @@ NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static Expected: all pass. If the new bridge is in its own project, run its test/lint/build target as well. -- [ ] **Step 3: Lint and build affected projects** +Verified: + +```bash +NX_DAEMON=false npx nx test ag-ui --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx test langgraph --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static +``` + +- [x] **Step 3: Lint and build affected projects** Run the smallest affected Nx lint/build targets. If API docs changed, verify generated docs are committed. -- [ ] **Step 4: Forbidden-reference scan** +Verified: + +```bash +NX_DAEMON=false npx nx lint chat --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build chat --skip-nx-cache --outputStyle=static +``` + +- [x] **Step 4: Forbidden-reference scan** Before committing code, run a staged diff scan excluding `docs/superpowers/**` and confirm no forbidden external framework names appear in code, comments, commit text, or PR text. -- [ ] **Step 5: Diff audit** +Verified with a staged diff scan excluding `docs/superpowers/**`; no forbidden references appeared in code or comments. + +- [x] **Step 5: Diff audit** Confirm the diff contains no unrelated refactors, no package dependency changes unless explicitly approved, and no behavior changes to existing AG-UI or LangGraph adapters. +Verified: diff is limited to the private adapter proof, its tests, and this plan status update. + --- ## Current Recommendation diff --git a/libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts b/libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts new file mode 100644 index 000000000..d240c8b8b --- /dev/null +++ b/libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT +import { signal, type Signal, type WritableSignal } from '@angular/core'; +import { describe, expect, it } from 'vitest'; +import { runAgentConformance } from '@threadplane/chat/testing'; +import { toSignalChatResourceAgent } from './signal-chat-resource-agent'; + +interface ResourceToolCall { + status: 'pending' | 'done'; + name: string; + toolCallId: string; + args: unknown; + result?: PromiseSettledResult; +} + +type ResourceMessage = + | { role: 'user'; content: unknown } + | { role: 'assistant'; content?: unknown; toolCalls: ResourceToolCall[] } + | { role: 'error'; content: string }; + +interface FakeSignalChatResource { + value: Signal; + isLoading: Signal; + error: Signal; + sendMessage: (message: { role: 'user'; content: unknown }) => void; + stop: () => void; + setMessages: (messages: ResourceMessage[]) => void; + resendMessages?: () => void; +} + +function makeResource(options: { + messages?: ResourceMessage[]; + isLoading?: boolean; + error?: Error; + withResend?: boolean; +} = {}) { + const messages = signal(options.messages ?? []); + const isLoading = signal(options.isLoading ?? false); + const error = signal(options.error); + const sent: Array<{ role: 'user'; content: unknown }> = []; + const setMessagesCalls: ResourceMessage[][] = []; + let stopCount = 0; + let resendCount = 0; + + const resource: FakeSignalChatResource = { + value: messages.asReadonly(), + isLoading: isLoading.asReadonly(), + error: error.asReadonly(), + sendMessage: (message) => { + sent.push(message); + messages.update((prev) => [...prev, message]); + }, + stop: () => { + if (!isLoading()) throw new Error('Cannot stop when idle'); + stopCount++; + isLoading.set(false); + }, + setMessages: (next) => { + setMessagesCalls.push(next); + messages.set(next); + }, + ...(options.withResend + ? { + resendMessages: () => { + resendCount++; + }, + } + : {}), + }; + + return { + resource, + messages: messages as WritableSignal, + isLoading, + error, + sent, + setMessagesCalls, + get stopCount() { + return stopCount; + }, + get resendCount() { + return resendCount; + }, + }; +} + +describe('toSignalChatResourceAgent', () => { + runAgentConformance('toSignalChatResourceAgent', () => { + return toSignalChatResourceAgent(makeResource().resource); + }); + + it('maps resource messages and tool calls to the Agent contract', () => { + const fixture = makeResource({ + messages: [ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'checking', + toolCalls: [ + { + status: 'pending', + name: 'lookup', + toolCallId: 'tool-1', + args: { city: 'Paris' }, + }, + { + status: 'done', + name: 'weather', + toolCallId: 'tool-2', + args: { city: 'Paris' }, + result: { status: 'fulfilled', value: { temp: 72 } }, + }, + { + status: 'done', + name: 'alerts', + toolCallId: 'tool-3', + args: { city: 'Paris' }, + result: { status: 'rejected', reason: new Error('offline') }, + }, + ], + }, + { role: 'error', content: 'model failed' }, + ], + }); + + const agent = toSignalChatResourceAgent(fixture.resource); + + expect(agent.messages()).toEqual([ + { id: 'resource-message-0', role: 'user', content: 'hello' }, + { + id: 'resource-message-1', + role: 'assistant', + content: 'checking', + toolCallIds: ['tool-1', 'tool-2', 'tool-3'], + }, + { + id: 'resource-message-2', + role: 'assistant', + content: 'model failed', + }, + ]); + expect(agent.toolCalls()).toEqual([ + { + id: 'tool-1', + name: 'lookup', + args: { city: 'Paris' }, + status: 'running', + }, + { + id: 'tool-2', + name: 'weather', + args: { city: 'Paris' }, + status: 'complete', + result: { temp: 72 }, + }, + { + id: 'tool-3', + name: 'alerts', + args: { city: 'Paris' }, + status: 'error', + error: expect.any(Error), + }, + ]); + }); + + it('derives status from loading and error signals', () => { + const fixture = makeResource(); + const agent = toSignalChatResourceAgent(fixture.resource); + + expect(agent.status()).toBe('idle'); + fixture.isLoading.set(true); + expect(agent.status()).toBe('running'); + fixture.isLoading.set(false); + fixture.error.set(new Error('HTTP 500')); + expect(agent.status()).toBe('error'); + expect(agent.error()?.status).toBe(500); + }); + + it('submits string user messages to the resource', async () => { + const fixture = makeResource(); + const agent = toSignalChatResourceAgent(fixture.resource); + + await agent.submit({ message: 'hello' }); + + expect(fixture.sent).toEqual([{ role: 'user', content: 'hello' }]); + }); + + it('does not call resource stop while idle', async () => { + const fixture = makeResource(); + const agent = toSignalChatResourceAgent(fixture.resource); + + await expect(agent.stop()).resolves.toBeUndefined(); + + expect(fixture.stopCount).toBe(0); + }); + + it('delegates stop while loading', async () => { + const fixture = makeResource({ isLoading: true }); + const agent = toSignalChatResourceAgent(fixture.resource); + + await agent.stop(); + + expect(fixture.stopCount).toBe(1); + }); + + it('delegates retry when the resource supports resend', async () => { + const fixture = makeResource({ withResend: true }); + const agent = toSignalChatResourceAgent(fixture.resource); + + await agent.retry(); + + expect(fixture.resendCount).toBe(1); + }); + + it('trims through the previous user message and resends on regenerate', async () => { + const messages: ResourceMessage[] = [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first answer', toolCalls: [] }, + { role: 'user', content: 'second' }, + { role: 'assistant', content: 'second answer', toolCalls: [] }, + ]; + const fixture = makeResource({ messages, withResend: true }); + const agent = toSignalChatResourceAgent(fixture.resource); + + await agent.regenerate(3); + + expect(fixture.setMessagesCalls).toEqual([ + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first answer', toolCalls: [] }, + { role: 'user', content: 'second' }, + ], + ]); + expect(fixture.resendCount).toBe(1); + }); + + it('throws when regenerating a non-assistant message', async () => { + const fixture = makeResource({ + messages: [{ role: 'user', content: 'hello' }], + }); + const agent = toSignalChatResourceAgent(fixture.resource); + + await expect(agent.regenerate(0)).rejects.toThrow( + 'Message at index 0 is not an assistant message', + ); + }); +}); diff --git a/libs/chat/src/lib/agent/signal-chat-resource-agent.ts b/libs/chat/src/lib/agent/signal-chat-resource-agent.ts new file mode 100644 index 000000000..54ef28d86 --- /dev/null +++ b/libs/chat/src/lib/agent/signal-chat-resource-agent.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +import { computed, signal, type Signal } from '@angular/core'; +import { EMPTY } from 'rxjs'; +import type { Agent } from './agent'; +import type { Message } from './message'; +import type { ToolCall } from './tool-call'; +import type { AgentStatus } from './agent-status'; +import type { AgentSubmitInput } from './agent-submit'; +import { toAgentError } from './to-agent-error'; + +interface SignalChatResourceToolCall { + status: 'pending' | 'done'; + name: string; + toolCallId: string; + args: unknown; + result?: PromiseSettledResult; +} + +type SignalChatResourceMessage = + | { role: 'user'; content: unknown } + | { role: 'assistant'; content?: unknown; toolCalls: SignalChatResourceToolCall[] } + | { role: 'error'; content: string }; + +interface SignalChatResourceLike { + value: Signal; + isLoading: Signal; + error: Signal; + sendMessage: (message: { role: 'user'; content: unknown }) => void; + stop: () => void; + setMessages: (messages: SignalChatResourceMessage[]) => void; + resendMessages?: () => void; + reload?: () => boolean; +} + +/** + * Wrap a signal-backed chat resource in the runtime-neutral Agent contract. + */ +export function toSignalChatResourceAgent(resource: SignalChatResourceLike): Agent { + const messages = computed(() => { + return resource.value().map((message, index) => toAgentMessage(message, index)); + }); + + const toolCalls = computed(() => { + return resource.value().flatMap((message) => { + if (message.role !== 'assistant') return []; + return message.toolCalls.map(toAgentToolCall); + }); + }); + + const isLoading = computed(() => resource.isLoading()); + const error = computed(() => { + const raw = resource.error(); + return raw ? toAgentError(raw) : undefined; + }); + const status = computed(() => { + if (isLoading()) return 'running'; + if (error()) return 'error'; + return 'idle'; + }); + + return { + messages, + status, + isLoading, + error, + toolCalls, + state: signal>({}).asReadonly(), + events$: EMPTY, + submit: async (input: AgentSubmitInput) => { + if (input.message === undefined) return; + resource.sendMessage({ role: 'user', content: input.message }); + }, + stop: async () => { + if (!resource.isLoading()) return; + resource.stop(); + }, + retry: async () => { + resource.resendMessages?.(); + }, + regenerate: async (assistantMessageIndex: number) => { + if (resource.isLoading()) { + throw new Error('Cannot regenerate while agent is loading another response'); + } + const current = resource.value(); + const target = current[assistantMessageIndex]; + if (!target || target.role !== 'assistant') { + throw new Error(`Message at index ${assistantMessageIndex} is not an assistant message`); + } + const userIdx = current + .slice(0, assistantMessageIndex) + .map((m, i) => ({ m, i })) + .reverse() + .find(({ m }) => m.role === 'user')?.i; + if (userIdx === undefined) { + throw new Error('No user message found before the target assistant message'); + } + resource.setMessages(current.slice(0, userIdx + 1)); + if (resource.resendMessages) { + resource.resendMessages(); + } else { + resource.reload?.(); + } + }, + }; +} + +function toAgentMessage(message: SignalChatResourceMessage, index: number): Message { + const id = `resource-message-${index}`; + switch (message.role) { + case 'user': + return { id, role: 'user', content: normalizeContent(message.content) }; + case 'assistant': + return { + id, + role: 'assistant', + content: normalizeContent(message.content ?? ''), + toolCallIds: message.toolCalls.map((toolCall) => toolCall.toolCallId), + }; + case 'error': + return { id, role: 'assistant', content: message.content }; + } +} + +function toAgentToolCall(toolCall: SignalChatResourceToolCall): ToolCall { + if (toolCall.status === 'pending') { + return { + id: toolCall.toolCallId, + name: toolCall.name, + args: toolCall.args, + status: 'running', + }; + } + + if (toolCall.result?.status === 'rejected') { + return { + id: toolCall.toolCallId, + name: toolCall.name, + args: toolCall.args, + status: 'error', + error: toolCall.result.reason, + }; + } + + return { + id: toolCall.toolCallId, + name: toolCall.name, + args: toolCall.args, + status: 'complete', + result: toolCall.result?.value, + }; +} + +function normalizeContent(content: unknown): Message['content'] { + if (typeof content === 'string') return content; + if (content === undefined) return ''; + return JSON.stringify(content); +} From ebd996927633177948c6824019831c26c4d9d589 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 16:50:19 -0700 Subject: [PATCH 2/2] test: ignore aborted icon font loads in chat e2e --- examples/chat/angular/e2e/test-helpers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/chat/angular/e2e/test-helpers.ts b/examples/chat/angular/e2e/test-helpers.ts index 0857b8424..7535bc351 100644 --- a/examples/chat/angular/e2e/test-helpers.ts +++ b/examples/chat/angular/e2e/test-helpers.ts @@ -21,13 +21,19 @@ export function attachBrowserHygiene(page: Page): { page.on('requestfailed', (request) => { const failure = request.failure()?.errorText ?? ''; const url = request.url(); - if (/runs\/stream/.test(url) && /abort|ERR_ABORTED/i.test(failure)) return; + if (isBenignAbortedRequest(url, failure)) return; failedRequests.push(`${request.method()} ${url} ${failure}`.trim()); }); return { consoleErrors, failedRequests }; } +function isBenignAbortedRequest(url: string, failure: string): boolean { + if (!/abort|ERR_ABORTED/i.test(failure)) return false; + if (/runs\/stream/.test(url)) return true; + return /fonts\.gstatic\.com\/s\/materialsymbolsoutlined\/.+\.woff2/.test(url); +} + export async function openDemo(page: Page, path = '/embed'): Promise { await page.goto(path); await page.evaluate(() => {