From 4380a1547f4c7ccbfc5a01acae01595d333b0636 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 17:26:21 -0700 Subject: [PATCH] feat: add signal resource adapter package --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 6 +- .github/workflows/release-provenance.yml | 4 +- .../docs/signal-resource/api/api-docs.json | 172 ++++++ apps/website/scripts/generate-api-docs.ts | 1 + ...t-tools-m6-signal-resource-package-plan.md | 582 ++++++++++++++++++ libs/signal-resource/README.md | 75 +++ libs/signal-resource/eslint.config.mjs | 53 ++ libs/signal-resource/ng-package.json | 7 + libs/signal-resource/package.json | 28 + libs/signal-resource/project.json | 57 ++ .../src/lib/provide-agent.spec.ts | 59 ++ libs/signal-resource/src/lib/provide-agent.ts | 73 +++ .../src/lib/provide-agent.type-spec.ts | 32 + libs/signal-resource/src/lib/to-agent.spec.ts | 247 ++++++++ libs/signal-resource/src/lib/to-agent.ts | 177 ++++++ libs/signal-resource/src/public-api.ts | 9 + libs/signal-resource/src/test-setup.ts | 11 + .../src/testing/fake-signal-chat-resource.ts | 97 +++ .../src/testing/type-assert.ts | 4 + libs/signal-resource/tsconfig.json | 23 + libs/signal-resource/tsconfig.lib.json | 18 + libs/signal-resource/tsconfig.lib.prod.json | 9 + libs/signal-resource/tsconfig.spec.json | 8 + libs/signal-resource/tsconfig.type-tests.json | 18 + libs/signal-resource/vite.config.mts | 13 + nx.json | 3 +- package-lock.json | 14 + scripts/ci-scope.mjs | 17 +- scripts/ci-scope.spec.mjs | 6 + tsconfig.base.json | 1 + 31 files changed, 1817 insertions(+), 9 deletions(-) create mode 100644 apps/website/content/docs/signal-resource/api/api-docs.json create mode 100644 docs/superpowers/plans/2026-07-08-client-tools-m6-signal-resource-package-plan.md create mode 100644 libs/signal-resource/README.md create mode 100644 libs/signal-resource/eslint.config.mjs create mode 100644 libs/signal-resource/ng-package.json create mode 100644 libs/signal-resource/package.json create mode 100644 libs/signal-resource/project.json create mode 100644 libs/signal-resource/src/lib/provide-agent.spec.ts create mode 100644 libs/signal-resource/src/lib/provide-agent.ts create mode 100644 libs/signal-resource/src/lib/provide-agent.type-spec.ts create mode 100644 libs/signal-resource/src/lib/to-agent.spec.ts create mode 100644 libs/signal-resource/src/lib/to-agent.ts create mode 100644 libs/signal-resource/src/public-api.ts create mode 100644 libs/signal-resource/src/test-setup.ts create mode 100644 libs/signal-resource/src/testing/fake-signal-chat-resource.ts create mode 100644 libs/signal-resource/src/testing/type-assert.ts create mode 100644 libs/signal-resource/tsconfig.json create mode 100644 libs/signal-resource/tsconfig.lib.json create mode 100644 libs/signal-resource/tsconfig.lib.prod.json create mode 100644 libs/signal-resource/tsconfig.spec.json create mode 100644 libs/signal-resource/tsconfig.type-tests.json create mode 100644 libs/signal-resource/vite.config.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e75e466da..ee7bdc379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: if: github.event_name == 'push' || needs.ci-scope.outputs.library == 'true' runs-on: ubuntu-latest env: - LIBS: chat,langgraph,ag-ui,render,a2ui,licensing,telemetry + LIBS: chat,langgraph,ag-ui,render,a2ui,licensing,signal-resource,telemetry steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5567d0d02..9ce10902c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,7 @@ jobs: contents: read id-token: write # required for npm trusted publishing + provenance env: - NPM_PUBLISHABLE_PROJECTS: chat,langgraph,ag-ui,render,a2ui,licensing,telemetry + NPM_PUBLISHABLE_PROJECTS: chat,langgraph,ag-ui,render,a2ui,licensing,signal-resource,telemetry steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Node 24 ships npm 11+ which fully implements npm trusted publishing @@ -57,10 +57,10 @@ jobs: run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache - name: Patch install telemetry into publishable manifests - run: node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing + run: node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/signal-resource - name: Verify install telemetry in publishable manifests - run: node libs/telemetry/scripts/verify-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/telemetry + run: node libs/telemetry/scripts/verify-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/signal-resource dist/libs/telemetry - name: Verify atomic release versions run: node scripts/verify-release-versions.mjs --tag "$RELEASE_TAG" diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml index 3cce243c7..4513eb021 100644 --- a/.github/workflows/release-provenance.yml +++ b/.github/workflows/release-provenance.yml @@ -33,7 +33,7 @@ jobs: hashes: ${{ steps.hash.outputs.hashes }} tag: ${{ steps.tag.outputs.tag }} env: - NPM_PUBLISHABLE_PROJECTS: chat,langgraph,ag-ui,render,a2ui,licensing,telemetry + NPM_PUBLISHABLE_PROJECTS: chat,langgraph,ag-ui,render,a2ui,licensing,signal-resource,telemetry steps: - name: Resolve target tag id: tag @@ -53,7 +53,7 @@ jobs: - name: Pack tarballs run: | mkdir -p release-artifacts - for p in chat langgraph ag-ui render a2ui licensing telemetry; do + for p in chat langgraph ag-ui render a2ui licensing signal-resource telemetry; do npm pack "dist/libs/$p" --pack-destination release-artifacts done - name: Generate subject hashes diff --git a/apps/website/content/docs/signal-resource/api/api-docs.json b/apps/website/content/docs/signal-resource/api/api-docs.json new file mode 100644 index 000000000..035d11c83 --- /dev/null +++ b/apps/website/content/docs/signal-resource/api/api-docs.json @@ -0,0 +1,172 @@ +[ + { + "name": "SignalChatResourceLike", + "kind": "interface", + "description": "Minimal structural contract required to adapt a signal-backed chat resource.", + "properties": [ + { + "name": "error", + "type": "Signal", + "description": "Current resource error, if any.", + "optional": false + }, + { + "name": "isLoading", + "type": "Signal", + "description": "Whether the resource is currently producing a response.", + "optional": false + }, + { + "name": "reload", + "type": "() => boolean", + "description": "Reload the resource, used as a fallback for `Agent.regenerate()`.", + "optional": true + }, + { + "name": "resendMessages", + "type": "() => void", + "description": "Re-run against the current history, if supported by the resource.", + "optional": true + }, + { + "name": "sendMessage", + "type": "(message: object) => void", + "description": "Send a new user message.", + "optional": false + }, + { + "name": "setMessages", + "type": "(messages: SignalChatResourceMessage[]) => void", + "description": "Replace resource message history. Used by `Agent.regenerate()`.", + "optional": false + }, + { + "name": "stop", + "type": "() => void", + "description": "Stop the current response. Called only while `isLoading()` is true.", + "optional": false + }, + { + "name": "value", + "type": "Signal", + "description": "Current resource message history.", + "optional": false + } + ], + "examples": [] + }, + { + "name": "SignalChatResourceToolCall", + "kind": "interface", + "description": "Tool-call shape exposed by a signal-backed chat resource.", + "properties": [ + { + "name": "args", + "type": "unknown", + "description": "Parsed tool arguments.", + "optional": false + }, + { + "name": "name", + "type": "string", + "description": "Tool name shown in Threadplane tool-call UI.", + "optional": false + }, + { + "name": "result", + "type": "PromiseSettledResult", + "description": "Settled tool result, when the resource exposes one.", + "optional": true + }, + { + "name": "status", + "type": "\"pending\" | \"done\"", + "description": "Resource-specific lifecycle state for the tool call.", + "optional": false + }, + { + "name": "toolCallId", + "type": "string", + "description": "Stable identifier for linking assistant messages to tool-call details.", + "optional": false + } + ], + "examples": [] + }, + { + "name": "SignalChatResourceMessage", + "kind": "type", + "description": "Message shape consumed by the signal-resource adapter.", + "signature": "object | object | object", + "examples": [] + }, + { + "name": "SignalResourceAgentSource", + "kind": "type", + "description": "Resource instance or factory accepted by `provideAgent()`.", + "signature": "SignalChatResourceLike | () => SignalChatResourceLike", + "examples": [] + }, + { + "name": "injectAgent", + "kind": "function", + "description": "Injects the signal-resource-backed Agent registered by provideAgent().", + "signature": "injectAgent(): Agent<>", + "params": [], + "returns": { + "type": "Agent<>", + "description": "" + }, + "examples": [ + "```ts\nimport { injectAgent } from '@threadplane/signal-resource';\n\nconst agent = injectAgent();\n```" + ] + }, + { + "name": "provideAgent", + "kind": "function", + "description": "Provides a signal-resource-backed Agent through Angular dependency injection.", + "signature": "provideAgent(ref: AgentRef, sourceOrFactory: SignalResourceAgentSource): Provider[]", + "params": [ + { + "name": "ref", + "type": "AgentRef", + "description": "", + "optional": false + }, + { + "name": "sourceOrFactory", + "type": "SignalResourceAgentSource", + "description": "", + "optional": false + } + ], + "returns": { + "type": "Provider[]", + "description": "" + }, + "examples": [ + "```ts\nimport { provideAgent } from '@threadplane/signal-resource';\n\nbootstrapApplication(AppComponent, {\n providers: [provideAgent(() => resource)],\n});\n```" + ] + }, + { + "name": "toAgent", + "kind": "function", + "description": "Wrap a signal-backed chat resource in the runtime-neutral Agent contract.", + "signature": "toAgent(resource: SignalChatResourceLike): Agent<>", + "params": [ + { + "name": "resource", + "type": "SignalChatResourceLike", + "description": "", + "optional": false + } + ], + "returns": { + "type": "Agent<>", + "description": "" + }, + "examples": [ + "```ts\nimport { toAgent } from '@threadplane/signal-resource';\n\nconst agent = toAgent(resource);\n```" + ] + } +] \ No newline at end of file diff --git a/apps/website/scripts/generate-api-docs.ts b/apps/website/scripts/generate-api-docs.ts index 9a9e0506a..047085319 100644 --- a/apps/website/scripts/generate-api-docs.ts +++ b/apps/website/scripts/generate-api-docs.ts @@ -182,6 +182,7 @@ const LIBRARIES: LibraryEntryConfig[] = [ { docSlug: 'chat', entryPoints: ['libs/chat/src/public-api.ts', 'libs/chat/testing/public-api.ts'] }, { docSlug: 'render', entryPoints: ['libs/render/src/public-api.ts'] }, { docSlug: 'ag-ui', entryPoints: ['libs/ag-ui/src/public-api.ts'] }, + { docSlug: 'signal-resource', entryPoints: ['libs/signal-resource/src/public-api.ts'] }, { docSlug: 'a2ui', entryPoints: ['libs/a2ui/src/index.ts'] }, { docSlug: 'middleware', entryPoints: ['libs/middleware/src/langgraph/index.ts'] }, { docSlug: 'licensing', entryPoints: ['libs/licensing/src/index.ts'] }, diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m6-signal-resource-package-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m6-signal-resource-package-plan.md new file mode 100644 index 000000000..9f3ab0b1d --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-client-tools-m6-signal-resource-package-plan.md @@ -0,0 +1,582 @@ +# Client Tools M6 Signal Resource Package 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:** Productize the M6 private signal-resource adapter proof as a provider-neutral Threadplane adapter package that lets signal-backed chat resources drive `@threadplane/chat` through the runtime-neutral `Agent` contract. + +**Architecture:** Add a new Angular package, `@threadplane/signal-resource`, that exposes `toAgent(resource)`, `provideAgent(...)`, and `injectAgent(...)` with the same consumer shape as the existing adapter packages. Keep the adapter generic: it wraps a structural signal-backed chat-resource interface and does not import or name any upstream provider package in runtime code, comments, commits, or PR text. The public package lifts the proven private projection from `libs/chat` into its own boundary, backed by conformance tests, DI tests, type tests, generated API docs, and package README/docs. + +**Tech Stack:** Angular signals and DI, RxJS `EMPTY`, Vitest, Nx Angular package targets, TypeDoc API docs, existing `@threadplane/chat` `Agent` and testing contracts. No new dependencies. + +--- + +## Source Of Truth + +- Spec: `docs/superpowers/specs/2026-07-07-client-tool-continuation-architecture-design.md` §8, §10 M6, and §12 open item 2. +- Handoff: `docs/superpowers/context/2026-07-07-client-tools-continuation-handoff.md`. +- Provider discovery: `docs/superpowers/context/2026-07-08-client-tools-m6-provider-contract-notes.md`. +- Private proof merged in PR #796: + - `libs/chat/src/lib/agent/signal-chat-resource-agent.ts` + - `libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts` +- Existing package patterns: + - `libs/ag-ui/project.json` + - `libs/ag-ui/src/public-api.ts` + - `libs/ag-ui/src/lib/provide-agent.ts` + - `libs/langgraph/project.json` + - `libs/langgraph/src/public-api.ts` + - `libs/langgraph/src/lib/agent.provider.ts` +- API docs generator: + - `apps/website/scripts/generate-api-docs.ts` + +## Scope Guard + +- Do not add runtime dependencies. +- Do not import upstream provider packages. The package is structural and provider-neutral. +- Do not mention external framework names in runtime code, code comments, package README/docs outside `docs/superpowers/**`, commit messages, or PR text. +- Do not alter `Agent`, `ClientToolsCapability`, existing AG-UI behavior, existing LangGraph behavior, or the M5 continuation policy. +- Do not map provider-owned tools into `ClientToolsCapability` in this phase. The package may project observed tool-call state into `Agent.toolCalls`, but it must not claim result-return ownership. +- Public exports require generated API docs in the same PR. + +## File Structure + +- Create: `libs/signal-resource/project.json` + - Nx project definition for a buildable Angular library named `signal-resource`. +- Create: `libs/signal-resource/package.json` + - Published package manifest for `@threadplane/signal-resource`. +- Create: `libs/signal-resource/ng-package.json` + - ng-packagr entrypoint. +- Create: `libs/signal-resource/tsconfig.json` +- Create: `libs/signal-resource/tsconfig.lib.json` +- Create: `libs/signal-resource/tsconfig.lib.prod.json` +- Create: `libs/signal-resource/tsconfig.spec.json` +- Create: `libs/signal-resource/tsconfig.type-tests.json` +- Create: `libs/signal-resource/vite.config.mts` +- Create: `libs/signal-resource/eslint.config.mjs` +- Create: `libs/signal-resource/src/test-setup.ts` +- Create: `libs/signal-resource/src/public-api.ts` + - Public exports only. +- Create: `libs/signal-resource/src/lib/to-agent.ts` + - Structural signal-resource-to-`Agent` adapter. +- Create: `libs/signal-resource/src/lib/to-agent.spec.ts` + - Behavior and conformance tests. +- Create: `libs/signal-resource/src/lib/provide-agent.ts` + - Angular DI provider/inject helpers. +- Create: `libs/signal-resource/src/lib/provide-agent.spec.ts` + - Runtime DI tests. +- Create: `libs/signal-resource/src/lib/provide-agent.type-spec.ts` + - Public type inference tests. +- Create: `libs/signal-resource/src/testing/fake-signal-chat-resource.ts` + - Test-only fake used by package tests and optionally exported for consumer tests only if the API is intentionally useful. +- Create or modify: `libs/signal-resource/README.md` + - Provider-neutral package usage. +- Modify: `tsconfig.base.json` + - Add `@threadplane/signal-resource` path. +- Modify: `package-lock.json` + - Add npm workspace lock metadata for `libs/signal-resource`. +- Modify: `apps/website/scripts/generate-api-docs.ts` + - Add `signal-resource` API docs entry. +- Create: `apps/website/content/docs/signal-resource/api/api-docs.json` + - Generated by `npm run generate-api-docs`. +- Optional create: `apps/website/content/docs/signal-resource/getting-started/quickstart.mdx` + - Only if docs navigation already supports docs folders without extra config. + +--- + +## Task 1: Scaffold The Public Package + +**Files:** +- Create: `libs/signal-resource/project.json` +- Create: `libs/signal-resource/package.json` +- Create: `libs/signal-resource/ng-package.json` +- Create: `libs/signal-resource/tsconfig.json` +- Create: `libs/signal-resource/tsconfig.lib.json` +- Create: `libs/signal-resource/tsconfig.lib.prod.json` +- Create: `libs/signal-resource/tsconfig.spec.json` +- Create: `libs/signal-resource/tsconfig.type-tests.json` +- Create: `libs/signal-resource/vite.config.mts` +- Create: `libs/signal-resource/eslint.config.mjs` +- Create: `libs/signal-resource/src/test-setup.ts` +- Create: `libs/signal-resource/src/public-api.ts` +- Modify: `tsconfig.base.json` +- Modify: `package-lock.json` + +- [x] **Step 1: Copy package scaffolding from an existing adapter** + +Use `libs/ag-ui` as the closest package template because it is also a runtime adapter around `@threadplane/chat`. + +Expected project shape: + +```json +{ + "name": "signal-resource", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/signal-resource/src", + "prefix": "signal-resource", + "projectType": "library", + "tags": [ + "scope:cockpit", + "scope:cockpit-deploy-smoke", + "scope:cockpit-e2e", + "scope:cockpit-examples", + "scope:cockpit-smoke", + "scope:examples-chat", + "scope:library", + "scope:website", + "scope:website-e2e" + ], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/signal-resource/ng-package.json", + "tsConfig": "libs/signal-resource/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/signal-resource/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "libs/signal-resource/vite.config.mts" + } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit -p libs/signal-resource/tsconfig.type-tests.json" + } + } + } +} +``` + +- [x] **Step 2: Add the package manifest** + +Use peer dependencies only: + +```json +{ + "name": "@threadplane/signal-resource", + "version": "0.0.56", + "description": "Signal-backed chat resource adapter for @threadplane/chat.", + "keywords": ["angular", "signals", "agent", "adapter", "threadplane"], + "peerDependencies": { + "@threadplane/chat": "*", + "@angular/core": "^20.0.0 || ^21.0.0", + "rxjs": "~7.8.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cacheplane/angular-agent-framework.git", + "directory": "libs/signal-resource" + }, + "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", + "bugs": { + "url": "https://github.com/cacheplane/angular-agent-framework/issues" + }, + "sideEffects": false +} +``` + +- [x] **Step 3: Add the TypeScript path** + +Add: + +```json +"@threadplane/signal-resource": ["libs/signal-resource/src/public-api.ts"] +``` + +to `compilerOptions.paths` in `tsconfig.base.json`. + +- [x] **Step 4: Add a placeholder public API** + +Create `libs/signal-resource/src/public-api.ts` with SPDX header and no runtime exports yet: + +```ts +// SPDX-License-Identifier: MIT +``` + +- [x] **Step 5: Verify Nx recognizes the project** + +Run: + +```bash +NX_DAEMON=false npx nx show project signal-resource +``` + +Expected: project JSON is printed with `build`, `lint`, `test`, and `type-tests` targets. + +## Task 2: Port The Adapter With Public Structural Types + +**Files:** +- Create: `libs/signal-resource/src/lib/to-agent.ts` +- Create: `libs/signal-resource/src/lib/to-agent.spec.ts` +- Create: `libs/signal-resource/src/testing/fake-signal-chat-resource.ts` +- Modify: `libs/signal-resource/src/public-api.ts` + +- [x] **Step 1: Write the fake resource first** + +Create a test fake that mirrors the structural public resource contract: + +```ts +export interface FakeSignalChatResource { + value: Signal; + isLoading: Signal; + error: Signal; + sendMessage: (message: { role: 'user'; content: unknown }) => void; + stop: () => void; + setMessages: (messages: SignalChatResourceMessage[]) => void; + resendMessages?: () => void; + reload?: () => boolean; +} +``` + +The fake must track sent messages, `setMessages` calls, `stop` calls, `resendMessages` calls, and `reload` calls. + +- [x] **Step 2: Write failing conformance and behavior tests** + +Port and expand the private proof tests from `libs/chat/src/lib/agent/signal-chat-resource-agent.spec.ts`. + +Required tests: + +- `runAgentConformance('signal-resource toAgent', ...)` passes for a fresh fake. +- Resource messages map to `Agent.messages`. +- Assistant tool calls map to `Agent.toolCalls`. +- `isLoading` and `error` derive `status`. +- `submit({ message })` delegates to `sendMessage({ role: 'user', content })`. +- `submit({ message: undefined })` is a no-op. +- `stop()` is a no-op while idle. +- `stop()` delegates while loading. +- `retry()` delegates to `resendMessages` when available. +- `retry()` is a no-op when unavailable. +- `regenerate(index)` rejects while loading. +- `regenerate(index)` rejects for non-assistant targets. +- `regenerate(index)` rejects when no prior user message exists. +- `regenerate(index)` trims through the prior user message and calls `resendMessages` when available. +- `regenerate(index)` trims through the prior user message and calls `reload` when `resendMessages` is unavailable. +- Non-string content serializes deterministically. + +- [x] **Step 3: Run the new focused tests and confirm failure** + +Run: + +```bash +NX_DAEMON=false npx nx test signal-resource --skip-nx-cache --outputStyle=static --testFile=src/lib/to-agent.spec.ts +``` + +Expected: FAIL because `toAgent` and public types are not implemented/exported yet. + +- [x] **Step 4: Implement `toAgent(resource)`** + +Create public structural types: + +```ts +export interface SignalChatResourceToolCall { + status: 'pending' | 'done'; + name: string; + toolCallId: string; + args: unknown; + result?: PromiseSettledResult; +} + +export type SignalChatResourceMessage = + | { role: 'user'; content: unknown } + | { role: 'assistant'; content?: unknown; toolCalls: SignalChatResourceToolCall[] } + | { role: 'error'; content: string }; + +export 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; +} +``` + +Implement: + +```ts +export function toAgent(resource: SignalChatResourceLike): Agent { + // Port the private proof behavior from libs/chat, keeping the public + // package provider-neutral and structurally typed. +} +``` + +Keep `events$` as `EMPTY`, `state` as an empty readonly signal, and do not expose any client-tools capability. + +- [x] **Step 5: Export public adapter API** + +Update `libs/signal-resource/src/public-api.ts`: + +```ts +// SPDX-License-Identifier: MIT +export { toAgent } from './lib/to-agent'; +export type { + SignalChatResourceLike, + SignalChatResourceMessage, + SignalChatResourceToolCall, +} from './lib/to-agent'; +``` + +- [x] **Step 6: Run focused tests and confirm pass** + +Run: + +```bash +NX_DAEMON=false npx nx test signal-resource --skip-nx-cache --outputStyle=static --testFile=src/lib/to-agent.spec.ts +``` + +Expected: PASS. + +## Task 3: Add Angular DI Helpers + +**Files:** +- Create: `libs/signal-resource/src/lib/provide-agent.ts` +- Create: `libs/signal-resource/src/lib/provide-agent.spec.ts` +- Create: `libs/signal-resource/src/lib/provide-agent.type-spec.ts` +- Modify: `libs/signal-resource/src/public-api.ts` + +- [x] **Step 1: Write failing provider tests** + +Cover: + +- `provideAgent(resource)` registers an injectable `Agent`. +- `provideAgent(() => resource)` runs the factory in an Angular injection context. +- `provideAgent(ref, resource)` also binds `ref.token`. +- `injectAgent()` returns the registered agent. +- `injectAgent(ref)` returns the typed ref-bound agent. + +- [x] **Step 2: Run provider tests and confirm failure** + +Run: + +```bash +NX_DAEMON=false npx nx test signal-resource --skip-nx-cache --outputStyle=static --testFile=src/lib/provide-agent.spec.ts +``` + +Expected: FAIL because the DI helpers do not exist yet. + +- [x] **Step 3: Implement provider helpers** + +Use the existing adapter overload shape: + +```ts +export type SignalResourceAgentSource = + | SignalChatResourceLike + | (() => SignalChatResourceLike); + +export function provideAgent>( + ref: AgentRef, + sourceOrFactory: SignalResourceAgentSource, +): Provider[]; +export function provideAgent(sourceOrFactory: SignalResourceAgentSource): Provider[]; + +export function injectAgent(): Agent; +export function injectAgent(ref: AgentRef): Agent; +``` + +The implementation should: + +- resolve function sources lazily inside the `useFactory` injection context; +- wrap the source with `toAgent(source)`; +- bind `ref.token` with `useExisting` when a ref is supplied. + +- [x] **Step 4: Export DI helpers** + +Update `libs/signal-resource/src/public-api.ts`: + +```ts +export { provideAgent, injectAgent } from './lib/provide-agent'; +export type { SignalResourceAgentSource } from './lib/provide-agent'; +``` + +- [x] **Step 5: Add public type tests** + +Use the local `testing/type-assert.ts` pattern from `libs/ag-ui` or `libs/langgraph` if needed. Verify: + +- `injectAgent()` is assignable to `Agent>`. +- `injectAgent(createAgentRef('x'))` is assignable to `Agent`. +- `provideAgent(ref, resource)` accepts a typed ref without forcing a generic at the call site. + +- [x] **Step 6: Run tests and type tests** + +Run: + +```bash +NX_DAEMON=false npx nx test signal-resource --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx run signal-resource:type-tests --skip-nx-cache --outputStyle=static +``` + +Expected: PASS. + +## Task 4: Docs And Generated API Docs + +**Files:** +- Create: `libs/signal-resource/README.md` +- Modify: `apps/website/scripts/generate-api-docs.ts` +- Create: `apps/website/content/docs/signal-resource/api/api-docs.json` +- Optional create: `apps/website/content/docs/signal-resource/getting-started/quickstart.mdx` + +- [x] **Step 1: Write provider-neutral package README** + +README content should show only generic signal-resource usage: + +```ts +import { provideAgent } from '@threadplane/signal-resource'; + +bootstrapApplication(AppComponent, { + providers: [ + provideAgent(() => { + const resource = createChatResourceSomewhere(); + return resource; + }), + ], +}); +``` + +Do not name upstream provider frameworks in this README. + +- [x] **Step 2: Add API docs generator entry** + +Add: + +```ts +{ docSlug: 'signal-resource', entryPoints: ['libs/signal-resource/src/public-api.ts'] }, +``` + +to the `LIBRARIES` array in `apps/website/scripts/generate-api-docs.ts`. + +- [x] **Step 3: Generate API docs** + +Run: + +```bash +npm run generate-api-docs +``` + +Expected: `apps/website/content/docs/signal-resource/api/api-docs.json` is written with entries for `toAgent`, `provideAgent`, `injectAgent`, and public types. + +- [x] **Step 4: Add minimal narrative docs only if routing is obvious** + +If existing docs routing automatically discovers content folders, add a concise quickstart at: + +```text +apps/website/content/docs/signal-resource/getting-started/quickstart.mdx +``` + +If routing requires broader navigation changes, skip narrative docs in this PR and rely on README plus generated API docs. + +Decision: skipped narrative docs in this PR; README plus generated API docs cover the public surface without navigation changes. + +- [x] **Step 5: Regenerate public agent context if docs changed** + +If README or website docs changed in a way that affects public package guidance, run: + +```bash +npm run generate-agent-context +``` + +Expected: generated public agent context updates only if the generator detects relevant documentation changes. + +Verified: generator only changed existing generated version headers, so those header-only changes were reverted to keep this PR scoped. + +## Task 5: Verification And PR Readiness + +**Files:** +- No new implementation files beyond prior tasks. + +- [x] **Step 1: Run focused package verification** + +Run: + +```bash +NX_DAEMON=false npx nx test signal-resource --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx lint signal-resource --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build signal-resource --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx run signal-resource:type-tests --skip-nx-cache --outputStyle=static +``` + +Expected: all pass. + +- [x] **Step 2: Run affected existing package tests** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static +``` + +Expected: PASS. The existing private proof remains intact unless a deliberate follow-up removes it. + +- [x] **Step 3: Run API/docs verification** + +Run: + +```bash +npm run generate-api-docs +git diff -- apps/website/content/docs/signal-resource/api/api-docs.json +``` + +Expected: generated API docs are stable after a second run. + +- [x] **Step 4: Run forbidden-reference scan** + +Run: + +```bash +git diff -- . ':(exclude)docs/superpowers/**' | rg -i 'hashbrown|copilotkit|chatgpt|claude' +``` + +Expected: no matches. Mentions in `docs/superpowers/**` are allowed by the handoff; all code, README, website docs, commit text, and PR text must stay provider-neutral. + +- [x] **Step 5: Audit dependency and public API changes** + +Run: + +```bash +git diff -- package.json package-lock.json +git diff -- libs/signal-resource/package.json +git diff -- apps/website/scripts/generate-api-docs.ts +``` + +Expected: + +- no root dependency additions; +- `package-lock.json` contains only the new workspace package/link metadata; +- only peer dependencies in the new package; +- API docs generator includes the new package entry. + +- [x] **Step 6: Commit** + +Use a provider-neutral commit message: + +```bash +git add libs/signal-resource tsconfig.base.json apps/website/scripts/generate-api-docs.ts apps/website/content/docs/signal-resource docs/superpowers/plans/2026-07-08-client-tools-m6-signal-resource-package-plan.md +git commit -m "feat: add signal resource adapter package" +``` + +Expected: commit succeeds and contains no forbidden external framework references outside `docs/superpowers/**`. + +## Execution Notes + +- This is the next public productization step after the private M6 adapter proof, not a client-tool continuation behavior change. +- The upstream migration story remains documented in `docs/superpowers/**`; the package itself should read as a generic Threadplane adapter for any signal-backed chat resource. +- Keep the private `libs/chat/src/lib/agent/signal-chat-resource-agent.ts` in place for this PR unless removing it becomes a clearly scoped cleanup with equivalent coverage. A later cleanup can delete it after consumers migrate to the new package. diff --git a/libs/signal-resource/README.md b/libs/signal-resource/README.md new file mode 100644 index 000000000..e8b8bd449 --- /dev/null +++ b/libs/signal-resource/README.md @@ -0,0 +1,75 @@ +# @threadplane/signal-resource + +Signal-backed chat resource adapter for `@threadplane/chat`. + +Use this package when your chat runtime already exposes Angular signals for +message history, loading state, errors, and send/stop/retry methods. The adapter +wraps that structural resource shape in Threadplane's runtime-neutral `Agent` +contract, so the same `` UI can render it. + +## Install + +```bash +npm install @threadplane/chat @threadplane/signal-resource +``` + +## Provide An Agent + +```ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAgent } from '@threadplane/signal-resource'; +import { AppComponent } from './app.component'; +import { createChatResource } from './chat-resource'; + +bootstrapApplication(AppComponent, { + providers: [ + provideAgent(() => { + const resource = createChatResource(); + return resource; + }), + ], +}); +``` + +## Use With Chat + +```ts +import { Component } from '@angular/core'; +import { ChatComponent } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/signal-resource'; + +@Component({ + selector: 'app-root', + imports: [ChatComponent], + template: ``, +}) +export class AppComponent { + readonly agent = injectAgent(); +} +``` + +## Resource Shape + +The resource is structural. It does not need to inherit from a Threadplane base +class. + +```ts +import type { Signal } from '@angular/core'; +import type { + SignalChatResourceMessage, +} from '@threadplane/signal-resource'; + +interface ChatResource { + value: Signal; + isLoading: Signal; + error: Signal; + sendMessage(message: { role: 'user'; content: unknown }): void; + stop(): void; + setMessages(messages: SignalChatResourceMessage[]): void; + resendMessages?(): void; + reload?(): boolean; +} +``` + +The adapter projects resource tool-call state into `Agent.toolCalls`, but it +does not take over provider-owned tool execution or result submission. diff --git a/libs/signal-resource/eslint.config.mjs b/libs/signal-resource/eslint.config.mjs new file mode 100644 index 000000000..7ba8e81ef --- /dev/null +++ b/libs/signal-resource/eslint.config.mjs @@ -0,0 +1,53 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: [ + 'vite', + '@nx/vite', + 'vitest', + 'rxjs', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: ['signalResource'], + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: ['signal-resource'], + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + rules: {}, + }, +]; diff --git a/libs/signal-resource/ng-package.json b/libs/signal-resource/ng-package.json new file mode 100644 index 000000000..82c883a53 --- /dev/null +++ b/libs/signal-resource/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/signal-resource", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/signal-resource/package.json b/libs/signal-resource/package.json new file mode 100644 index 000000000..8892dee01 --- /dev/null +++ b/libs/signal-resource/package.json @@ -0,0 +1,28 @@ +{ + "name": "@threadplane/signal-resource", + "version": "0.0.56", + "description": "Signal-backed chat resource adapter for @threadplane/chat.", + "keywords": [ + "angular", + "signals", + "agent", + "adapter", + "threadplane" + ], + "peerDependencies": { + "@threadplane/chat": "*", + "@angular/core": "^20.0.0 || ^21.0.0", + "rxjs": "~7.8.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cacheplane/angular-agent-framework.git", + "directory": "libs/signal-resource" + }, + "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", + "bugs": { + "url": "https://github.com/cacheplane/angular-agent-framework/issues" + }, + "sideEffects": false +} diff --git a/libs/signal-resource/project.json b/libs/signal-resource/project.json new file mode 100644 index 000000000..84783c052 --- /dev/null +++ b/libs/signal-resource/project.json @@ -0,0 +1,57 @@ +{ + "name": "signal-resource", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/signal-resource/src", + "prefix": "signal-resource", + "projectType": "library", + "tags": [ + "scope:cockpit", + "scope:cockpit-deploy-smoke", + "scope:cockpit-e2e", + "scope:cockpit-examples", + "scope:cockpit-smoke", + "scope:examples-chat", + "scope:library", + "scope:website", + "scope:website-e2e" + ], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": [ + "{workspaceRoot}/dist/{projectRoot}" + ], + "options": { + "project": "libs/signal-resource/ng-package.json", + "tsConfig": "libs/signal-resource/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/signal-resource/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "libs/signal-resource/vite.config.mts" + } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit -p libs/signal-resource/tsconfig.type-tests.json" + } + } + } +} diff --git a/libs/signal-resource/src/lib/provide-agent.spec.ts b/libs/signal-resource/src/lib/provide-agent.spec.ts new file mode 100644 index 000000000..fd64337c3 --- /dev/null +++ b/libs/signal-resource/src/lib/provide-agent.spec.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +import { inject, InjectionToken } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createAgentRef } from '@threadplane/chat'; +import { describe, expect, it } from 'vitest'; +import { makeFakeSignalChatResource } from '../testing/fake-signal-chat-resource'; +import { AGENT, injectAgent, provideAgent } from './provide-agent'; + +describe('provideAgent', () => { + it('registers an injectable Agent from a resource', () => { + const fixture = makeFakeSignalChatResource(); + TestBed.configureTestingModule({ + providers: [provideAgent(fixture.resource)], + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + + expect(agent).toBe(TestBed.inject(AGENT)); + expect(agent.messages()).toEqual([]); + }); + + it('resolves resource factories inside an injection context', () => { + const fixture = makeFakeSignalChatResource(); + const RESOURCE = new InjectionToken('RESOURCE', { + factory: () => fixture.resource, + }); + let calls = 0; + TestBed.configureTestingModule({ + providers: [ + { provide: RESOURCE, useValue: fixture.resource }, + provideAgent(() => { + calls++; + return inject(RESOURCE); + }), + ], + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + + expect(agent).toBeDefined(); + expect(calls).toBe(1); + }); + + it('binds an AgentRef token when provided', () => { + interface TripState { + day: number; + } + const TRIP = createAgentRef('trip'); + const fixture = makeFakeSignalChatResource(); + TestBed.configureTestingModule({ + providers: [provideAgent(TRIP, fixture.resource)], + }); + + const defaultAgent = TestBed.runInInjectionContext(() => injectAgent()); + const typedAgent = TestBed.runInInjectionContext(() => injectAgent(TRIP)); + + expect(typedAgent).toBe(defaultAgent); + }); +}); diff --git a/libs/signal-resource/src/lib/provide-agent.ts b/libs/signal-resource/src/lib/provide-agent.ts new file mode 100644 index 000000000..f310b7e47 --- /dev/null +++ b/libs/signal-resource/src/lib/provide-agent.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +import { InjectionToken, inject, type Provider } from '@angular/core'; +import type { Agent, AgentRef } from '@threadplane/chat'; +import { toAgent, type SignalChatResourceLike } from './to-agent'; + +/** Resource instance or factory accepted by `provideAgent()`. */ +export type SignalResourceAgentSource = + | SignalChatResourceLike + | (() => SignalChatResourceLike); + +/** + * @internal — exported for spec access only. Consumers should use injectAgent(). + */ +export const AGENT = new InjectionToken('SIGNAL_RESOURCE_AGENT'); + +function resolveSource(sourceOrFactory: SignalResourceAgentSource): SignalChatResourceLike { + return typeof sourceOrFactory === 'function' ? sourceOrFactory() : sourceOrFactory; +} + +function buildAgent(sourceOrFactory: SignalResourceAgentSource): Agent { + return toAgent(resolveSource(sourceOrFactory)); +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} + +/** + * Provides a signal-resource-backed Agent through Angular dependency injection. + * + * @example + * ```ts + * import { provideAgent } from '@threadplane/signal-resource'; + * + * bootstrapApplication(AppComponent, { + * providers: [provideAgent(() => resource)], + * }); + * ``` + */ +export function provideAgent>( + ref: AgentRef, + sourceOrFactory: SignalResourceAgentSource, +): Provider[]; +export function provideAgent(sourceOrFactory: SignalResourceAgentSource): Provider[]; +export function provideAgent>( + refOrSource: AgentRef | SignalResourceAgentSource, + maybeSource?: SignalResourceAgentSource, +): Provider[] { + const ref = isAgentRef(refOrSource) ? refOrSource : undefined; + const sourceOrFactory = (ref ? maybeSource : refOrSource) as SignalResourceAgentSource; + + const providers: Provider[] = [ + { provide: AGENT, useFactory: () => buildAgent(sourceOrFactory) }, + ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; +} + +/** + * Injects the signal-resource-backed Agent registered by provideAgent(). + * + * @example + * ```ts + * import { injectAgent } from '@threadplane/signal-resource'; + * + * const agent = injectAgent(); + * ``` + */ +export function injectAgent(): Agent; +export function injectAgent(ref: AgentRef): Agent; +export function injectAgent(ref?: AgentRef): Agent { + return inject(ref ? ref.token : AGENT) as Agent; +} diff --git a/libs/signal-resource/src/lib/provide-agent.type-spec.ts b/libs/signal-resource/src/lib/provide-agent.type-spec.ts new file mode 100644 index 000000000..950e555ca --- /dev/null +++ b/libs/signal-resource/src/lib/provide-agent.type-spec.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import type { Agent } from '@threadplane/chat'; +import { createAgentRef } from '@threadplane/chat'; +import { makeFakeSignalChatResource } from '../testing/fake-signal-chat-resource'; +import type { Equal, Expect } from '../testing/type-assert'; +import { injectAgent, provideAgent } from './provide-agent'; + +interface TripState { + day: number; + places: string[]; +} + +const TRIP = createAgentRef('trip'); +const fixture = makeFakeSignalChatResource(); + +declare function ctx(fn: () => T): T; + +const typed = ctx(() => injectAgent(TRIP)); +export type TypedAgentCheck = Expect>>; +export type TypedStateCheck = Expect, TripState>>; + +const plain = ctx(() => injectAgent()); +export type PlainAgentCheck = Expect>>>; +export type PlainStateCheck = Expect, Record>>; + +void typed; +void plain; + +provideAgent(TRIP, fixture.resource); +provideAgent(TRIP, () => fixture.resource); +provideAgent(fixture.resource); +provideAgent(() => fixture.resource); diff --git a/libs/signal-resource/src/lib/to-agent.spec.ts b/libs/signal-resource/src/lib/to-agent.spec.ts new file mode 100644 index 000000000..4825fbb8e --- /dev/null +++ b/libs/signal-resource/src/lib/to-agent.spec.ts @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { runAgentConformance } from '@threadplane/chat/testing'; +import { toAgent, type SignalChatResourceMessage } from './to-agent'; +import { makeFakeSignalChatResource } from '../testing/fake-signal-chat-resource'; + +describe('toAgent', () => { + runAgentConformance('signal-resource toAgent', () => { + return toAgent(makeFakeSignalChatResource().resource); + }); + + it('maps resource messages and tool calls to the Agent contract', () => { + const fixture = makeFakeSignalChatResource({ + 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 = toAgent(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 = makeFakeSignalChatResource(); + const agent = toAgent(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 = makeFakeSignalChatResource(); + const agent = toAgent(fixture.resource); + + await agent.submit({ message: 'hello' }); + + expect(fixture.sent).toEqual([{ role: 'user', content: 'hello' }]); + }); + + it('does not submit when message is undefined', async () => { + const fixture = makeFakeSignalChatResource(); + const agent = toAgent(fixture.resource); + + await agent.submit({}); + + expect(fixture.sent).toEqual([]); + }); + + it('does not call resource stop while idle', async () => { + const fixture = makeFakeSignalChatResource(); + const agent = toAgent(fixture.resource); + + await expect(agent.stop()).resolves.toBeUndefined(); + + expect(fixture.stopCount).toBe(0); + }); + + it('delegates stop while loading', async () => { + const fixture = makeFakeSignalChatResource({ isLoading: true }); + const agent = toAgent(fixture.resource); + + await agent.stop(); + + expect(fixture.stopCount).toBe(1); + }); + + it('delegates retry when the resource supports resend', async () => { + const fixture = makeFakeSignalChatResource({ withResend: true }); + const agent = toAgent(fixture.resource); + + await agent.retry(); + + expect(fixture.resendCount).toBe(1); + }); + + it('does not throw on retry when resend is unavailable', async () => { + const fixture = makeFakeSignalChatResource(); + const agent = toAgent(fixture.resource); + + await expect(agent.retry()).resolves.toBeUndefined(); + }); + + it('throws when regenerating while loading', async () => { + const fixture = makeFakeSignalChatResource({ + isLoading: true, + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi', toolCalls: [] }, + ], + }); + const agent = toAgent(fixture.resource); + + await expect(agent.regenerate(1)).rejects.toThrow( + 'Cannot regenerate while agent is loading another response', + ); + }); + + it('throws when regenerating a non-assistant message', async () => { + const fixture = makeFakeSignalChatResource({ + messages: [{ role: 'user', content: 'hello' }], + }); + const agent = toAgent(fixture.resource); + + await expect(agent.regenerate(0)).rejects.toThrow( + 'Message at index 0 is not an assistant message', + ); + }); + + it('throws when regenerating an assistant message without a previous user message', async () => { + const fixture = makeFakeSignalChatResource({ + messages: [{ role: 'assistant', content: 'hello', toolCalls: [] }], + }); + const agent = toAgent(fixture.resource); + + await expect(agent.regenerate(0)).rejects.toThrow( + 'No user message found before the target assistant message', + ); + }); + + it('trims through the previous user message and resends on regenerate', async () => { + const messages: SignalChatResourceMessage[] = [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first answer', toolCalls: [] }, + { role: 'user', content: 'second' }, + { role: 'assistant', content: 'second answer', toolCalls: [] }, + ]; + const fixture = makeFakeSignalChatResource({ messages, withResend: true }); + const agent = toAgent(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('falls back to reload on regenerate when resend is unavailable', async () => { + const messages: SignalChatResourceMessage[] = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi', toolCalls: [] }, + ]; + const fixture = makeFakeSignalChatResource({ messages, withReload: true }); + const agent = toAgent(fixture.resource); + + await agent.regenerate(1); + + expect(fixture.setMessagesCalls).toEqual([[{ role: 'user', content: 'hello' }]]); + expect(fixture.reloadCount).toBe(1); + }); + + it('serializes non-string message content deterministically', () => { + const fixture = makeFakeSignalChatResource({ + messages: [ + { role: 'user', content: { nested: ['value'] } }, + { role: 'assistant', content: { answer: 42 }, toolCalls: [] }, + ], + }); + const agent = toAgent(fixture.resource); + + expect(agent.messages()).toEqual([ + { + id: 'resource-message-0', + role: 'user', + content: '{"nested":["value"]}', + }, + { + id: 'resource-message-1', + role: 'assistant', + content: '{"answer":42}', + toolCallIds: [], + }, + ]); + }); +}); diff --git a/libs/signal-resource/src/lib/to-agent.ts b/libs/signal-resource/src/lib/to-agent.ts new file mode 100644 index 000000000..cc4e94d6f --- /dev/null +++ b/libs/signal-resource/src/lib/to-agent.ts @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +import { computed, signal, type Signal } from '@angular/core'; +import type { Agent, AgentSubmitInput, AgentStatus, Message, ToolCall } from '@threadplane/chat'; +import { toAgentError } from '@threadplane/chat'; +import { EMPTY } from 'rxjs'; + +/** Tool-call shape exposed by a signal-backed chat resource. */ +export interface SignalChatResourceToolCall { + /** Resource-specific lifecycle state for the tool call. */ + status: 'pending' | 'done'; + /** Tool name shown in Threadplane tool-call UI. */ + name: string; + /** Stable identifier for linking assistant messages to tool-call details. */ + toolCallId: string; + /** Parsed tool arguments. */ + args: unknown; + /** Settled tool result, when the resource exposes one. */ + result?: PromiseSettledResult; +} + +/** Message shape consumed by the signal-resource adapter. */ +export type SignalChatResourceMessage = + | { role: 'user'; content: unknown } + | { role: 'assistant'; content?: unknown; toolCalls: SignalChatResourceToolCall[] } + | { role: 'error'; content: string }; + +/** Minimal structural contract required to adapt a signal-backed chat resource. */ +export interface SignalChatResourceLike { + /** Current resource message history. */ + value: Signal; + /** Whether the resource is currently producing a response. */ + isLoading: Signal; + /** Current resource error, if any. */ + error: Signal; + /** Send a new user message. */ + sendMessage: (message: { role: 'user'; content: unknown }) => void; + /** Stop the current response. Called only while `isLoading()` is true. */ + stop: () => void; + /** Replace resource message history. Used by `Agent.regenerate()`. */ + setMessages: (messages: SignalChatResourceMessage[]) => void; + /** Re-run against the current history, if supported by the resource. */ + resendMessages?: () => void; + /** Reload the resource, used as a fallback for `Agent.regenerate()`. */ + reload?: () => boolean; +} + +/** + * Wrap a signal-backed chat resource in the runtime-neutral Agent contract. + * + * @example + * ```ts + * import { toAgent } from '@threadplane/signal-resource'; + * + * const agent = toAgent(resource); + * ``` + */ +export function toAgent(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 ''; + const serialized = JSON.stringify(content); + return serialized ?? String(content); +} diff --git a/libs/signal-resource/src/public-api.ts b/libs/signal-resource/src/public-api.ts new file mode 100644 index 000000000..3b5bfc14e --- /dev/null +++ b/libs/signal-resource/src/public-api.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +export { toAgent } from './lib/to-agent'; +export type { + SignalChatResourceLike, + SignalChatResourceMessage, + SignalChatResourceToolCall, +} from './lib/to-agent'; +export { provideAgent, injectAgent } from './lib/provide-agent'; +export type { SignalResourceAgentSource } from './lib/provide-agent'; diff --git a/libs/signal-resource/src/test-setup.ts b/libs/signal-resource/src/test-setup.ts new file mode 100644 index 000000000..ca3d8a2b3 --- /dev/null +++ b/libs/signal-resource/src/test-setup.ts @@ -0,0 +1,11 @@ +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/libs/signal-resource/src/testing/fake-signal-chat-resource.ts b/libs/signal-resource/src/testing/fake-signal-chat-resource.ts new file mode 100644 index 000000000..8fc527425 --- /dev/null +++ b/libs/signal-resource/src/testing/fake-signal-chat-resource.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +import { signal, type Signal, type WritableSignal } from '@angular/core'; +import type { + SignalChatResourceLike, + SignalChatResourceMessage, +} from '../lib/to-agent'; + +export interface FakeSignalChatResource extends SignalChatResourceLike { + value: Signal; + isLoading: Signal; + error: Signal; +} + +export interface FakeSignalChatResourceFixture { + resource: FakeSignalChatResource; + messages: WritableSignal; + isLoading: WritableSignal; + error: WritableSignal; + sent: Array<{ role: 'user'; content: unknown }>; + setMessagesCalls: SignalChatResourceMessage[][]; + stopCount: number; + resendCount: number; + reloadCount: number; +} + +export interface FakeSignalChatResourceOptions { + messages?: SignalChatResourceMessage[]; + isLoading?: boolean; + error?: Error; + withResend?: boolean; + withReload?: boolean; +} + +export function makeFakeSignalChatResource( + options: FakeSignalChatResourceOptions = {}, +): FakeSignalChatResourceFixture { + 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: SignalChatResourceMessage[][] = []; + let stopCount = 0; + let resendCount = 0; + let reloadCount = 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++; + }, + } + : {}), + ...(options.withReload + ? { + reload: () => { + reloadCount++; + return true; + }, + } + : {}), + }; + + return { + resource, + messages, + isLoading, + error, + sent, + setMessagesCalls, + get stopCount() { + return stopCount; + }, + get resendCount() { + return resendCount; + }, + get reloadCount() { + return reloadCount; + }, + }; +} diff --git a/libs/signal-resource/src/testing/type-assert.ts b/libs/signal-resource/src/testing/type-assert.ts new file mode 100644 index 000000000..63a48b0bb --- /dev/null +++ b/libs/signal-resource/src/testing/type-assert.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; diff --git a/libs/signal-resource/tsconfig.json b/libs/signal-resource/tsconfig.json new file mode 100644 index 000000000..da190b437 --- /dev/null +++ b/libs/signal-resource/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "experimentalDecorators": true, + "noPropertyAccessFromIndexSignature": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/signal-resource/tsconfig.lib.json b/libs/signal-resource/tsconfig.lib.json new file mode 100644 index 000000000..ce7efa590 --- /dev/null +++ b/libs/signal-resource/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../licensing/tsconfig.lib.json" + } + ] +} diff --git a/libs/signal-resource/tsconfig.lib.prod.json b/libs/signal-resource/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/signal-resource/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/signal-resource/tsconfig.spec.json b/libs/signal-resource/tsconfig.spec.json new file mode 100644 index 000000000..f18aabc77 --- /dev/null +++ b/libs/signal-resource/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"], + "lib": ["es2022", "dom"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/signal-resource/tsconfig.type-tests.json b/libs/signal-resource/tsconfig.type-tests.json new file mode 100644 index 000000000..47e1cd415 --- /dev/null +++ b/libs/signal-resource/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} diff --git a/libs/signal-resource/vite.config.mts b/libs/signal-resource/vite.config.mts new file mode 100644 index 000000000..ce406638a --- /dev/null +++ b/libs/signal-resource/vite.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/nx.json b/nx.json index eb610af5c..8f0e60bb4 100644 --- a/nx.json +++ b/nx.json @@ -52,13 +52,14 @@ "render", "a2ui", "licensing", + "signal-resource", "telemetry" ], "projectsRelationship": "fixed" } }, "version": { - "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry && node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing && node libs/telemetry/scripts/verify-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/telemetry && node libs/telemetry/scripts/smoke-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/telemetry", + "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,signal-resource,telemetry && node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/signal-resource && node libs/telemetry/scripts/verify-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/signal-resource dist/libs/telemetry && node libs/telemetry/scripts/smoke-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/signal-resource dist/libs/telemetry", "updateDependents": "auto", "preserveLocalDependencyProtocols": true, "currentVersionResolver": "git-tag", diff --git a/package-lock.json b/package-lock.json index 8b5f8afeb..e9f9d8fcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -375,6 +375,16 @@ "@json-render/core": "^0.16.0" } }, + "libs/signal-resource": { + "name": "@threadplane/signal-resource", + "version": "0.0.56", + "license": "MIT", + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0", + "@threadplane/chat": "*", + "rxjs": "~7.8.0" + } + }, "libs/telemetry": { "name": "@threadplane/telemetry", "version": "0.0.56", @@ -20566,6 +20576,10 @@ "resolved": "libs/render", "link": true }, + "node_modules/@threadplane/signal-resource": { + "resolved": "libs/signal-resource", + "link": true + }, "node_modules/@threadplane/telemetry": { "resolved": "libs/telemetry", "link": true diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index e94f9be44..2804f11ef 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -59,9 +59,10 @@ function tagToScopeKey(tag) { * @returns {Record} scope booleans keyed by SCOPE_KEYS */ export function classifyFromAffected(changedFiles, affectedProjects) { - for (const f of changedFiles) { - if (GLOBAL_CI_FILES.has(f)) return fullScope(); + if (hasGlobalCiFileChange(changedFiles)) { + return fullScope(); } + const scope = emptyScope(); for (const project of affectedProjects) { for (const tag of project.tags ?? []) { @@ -76,6 +77,10 @@ export function classifyFromAffected(changedFiles, affectedProjects) { return scope; } +export function hasGlobalCiFileChange(changedFiles) { + return changedFiles.some((f) => GLOBAL_CI_FILES.has(f)); +} + function changedFilesBetween(base, head, workspaceRoot) { return execFileSync('git', ['diff', '--name-only', base, head], { cwd: workspaceRoot, @@ -135,6 +140,14 @@ function main() { } const changedFiles = changedFilesBetween(args.base, args.head, workspaceRoot); + if (hasGlobalCiFileChange(changedFiles)) { + console.log('Changed files:'); + for (const f of changedFiles) console.log(` ${f}`); + writeOutputs(fullScope(), args.output); + console.log('Global CI file changed; running the full CI suite.'); + return; + } + const affectedProjects = loadAffectedProjects(args.base, args.head, workspaceRoot); const scope = classifyFromAffected(changedFiles, affectedProjects); diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 42cc7c81e..f8f852906 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -5,6 +5,7 @@ import { classifyFromAffected, emptyScope, fullScope, + hasGlobalCiFileChange, SCOPE_KEYS, } from './ci-scope.mjs'; @@ -21,6 +22,11 @@ const EXAMPLES_CHAT_TAGS = ['scope:examples-chat']; const POSTHOG_TAGS = ['scope:posthog']; describe('classifyFromAffected — short-circuit', () => { + it('detects global CI files before affected project lookup is needed', () => { + assert.equal(hasGlobalCiFileChange(['nx.json']), true); + assert.equal(hasGlobalCiFileChange(['libs/chat/src/foo.ts']), false); + }); + it('returns full scope when a global CI file changes', () => { const scope = classifyFromAffected(['.github/workflows/ci.yml'], []); assert.deepEqual(scope, fullScope()); diff --git a/tsconfig.base.json b/tsconfig.base.json index 84e274fd9..3cdbc076c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,7 @@ "@threadplane/marketing-channels": ["marketing/channels/src/index.ts"], "@threadplane/marketing-metrics": ["marketing/metrics/src/index.ts"], "@threadplane/render": ["libs/render/src/public-api.ts"], + "@threadplane/signal-resource": ["libs/signal-resource/src/public-api.ts"], "@threadplane/telemetry": ["libs/telemetry/src/index.ts"], "@threadplane/telemetry/browser": ["libs/telemetry/src/browser/public-api.ts"], "@threadplane/telemetry/node": ["libs/telemetry/src/node/index.ts"],