From 1276eed227aeb9e01fdc47d11a794b15350a2953 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Tue, 22 Sep 2026 22:48:30 -0700 Subject: [PATCH] feat: require CopilotKit Intelligence in every mode --- .env.example | 6 +-- CONTRIBUTING.md | 2 +- README.md | 15 ++++--- SECURITY.md | 2 +- apps/computer/smoke.test.ts | 1 + apps/server/src/agent.ts | 22 +++++----- apps/server/src/app.ts | 33 +++++++-------- apps/server/src/config.ts | 23 +++++++--- apps/server/src/demo/entry.ts | 3 ++ apps/server/src/index.ts | 3 +- apps/server/src/workspace.ts | 2 +- docs/DEMO.md | 4 +- docs/RICH-THREADS.md | 4 +- tests/agent-api.test.ts | 15 +++++-- tests/api.test.ts | 80 ++++++++++++++++++----------------- tests/config.test.ts | 28 ++++++------ tests/helpers/browser.ts | 1 + tests/helpers/computer.ts | 1 + tests/model-worker.test.ts | 1 + tests/workflows.test.ts | 1 + 20 files changed, 138 insertions(+), 109 deletions(-) diff --git a/.env.example b/.env.example index bbfefdacc..2136a81f7 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Copy to .env. Sample mode stays on localhost and uses fictional data. +# Every mode requires a server-only CopilotKit Intelligence project key. WORKSPACE_MODE=sample AGENT_BACKEND=sample PORT=8787 @@ -39,11 +40,10 @@ WORKER_HOST=127.0.0.1 # AGENT_TOKEN= # OpenBot's Intelligence runtime is not this raw AG-UI URL. See its adapter docs. -# CopilotKit Intelligence is required when WORKSPACE_MODE=live. Create or select -# a project with the commands below, then keep the generated key server-only. +# CopilotKit Intelligence is required in every mode. Create or select a project +# with the commands below, then keep the generated key server-only. # npx copilotkit@latest login # npx copilotkit@latest project select -# Sample mode can leave this unset and use the local conversation store. CPK_INTELLIGENCE_API_KEY= # Optional private Docker Linux computer (separate from the browser). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519da3329..84e681f4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ OpenMuse is an MIT-licensed alpha. Contributions should make delegated work reli ## Local development 1. Fork and clone the repository. Use Node 24 LTS and pnpm 11.19.0. -2. Run `pnpm install --frozen-lockfile` and copy `.env.example` to `.env`. +2. Run `pnpm install --frozen-lockfile`, copy `.env.example` to `.env`, and use `npx copilotkit@latest login` then `npx copilotkit@latest project select` to set the required Intelligence key. 3. Run `pnpm dev` and, in another terminal, `pnpm dev:web`. 4. Use the fictional sample workspace for development and recordings. See [native setup](apps/mobile/README.md) for simulator/emulator builds. diff --git a/README.md b/README.md index b52b0d2cc..1d4bd700e 100644 --- a/README.md +++ b/README.md @@ -55,19 +55,22 @@ The computer combines **persistent Chromium and an optional Linux workspace**. T | **Finance** | Import transaction CSV to create a spending summary with categories, transactions, and a savings-goal action. | | **Gmail & Calendar** | Google OAuth adapters, complete mail threads, drafts/attachments, calendar discovery, and reviewed event creation/update/deletion. Live credentials required. | | **Personal context** | Editable name, tone, avatar, and memories. Background-update preferences and durable in-app notifications. | -| **Rich Threads** | CopilotKit Intelligence persistence for live deployments, with a stable main conversation, side chats, renaming, archiving, restoring, and replay. A server-only project key is required in live mode; sample mode uses local history. | +| **Rich Threads** | CopilotKit Intelligence persistence in every mode, with a stable main conversation, side chats, renaming, archiving, restoring, and replay. A server-only project key is required. | The [feature inventory](docs/FEATURES.md) describes implemented capabilities and planned extensions. Health/bank/social connectors, device push, voice, generated executable tools, and automatic reservations/payments are on the [roadmap](ROADMAP.md). ## Quick start -**Requirements:** Node 24 LTS and pnpm 11.19.0. The local sample app needs no model, Google account, Docker, or Intelligence subscription. +**Requirements:** Node 24 LTS, pnpm 11.19.0, and a CopilotKit Intelligence project key. The local sample app needs no model, Google account, or Docker. ```sh git clone https://github.com/CopilotKit/OpenMuse.git openmuse cd openmuse pnpm install --frozen-lockfile cp .env.example .env +npx copilotkit@latest login +npx copilotkit@latest project select +# Set CPK_INTELLIGENCE_API_KEY in .env to the generated server-only project key. pnpm dev ``` @@ -84,7 +87,7 @@ Open [localhost:8081](http://localhost:8081). The API runs at [localhost:8787/ap 1. In Chat, send **“Complete the permission slip”**. Open the task, supply fictional form values, inspect the saved PDF, and review the prepared reply. This writes only to the local mailbox. 2. In **Goals → Track**, create a built-in availability watch, then change the built-in test page to trigger an alert. 3. In **Menu → Delegate task → Finance**, use **Try example transactions** to create an interactive spending tracker. -4. Start the [browser worker](#browser-worker) and configure a model, then ask **“Check out Hacker News for cool stuff”** or **“Summarize copilotkit.ai”**. Follow the browser inline and use **Take control** to open its session. For a key-free version of this flow, follow the [AI Mock demo setup](docs/DEMO.md#run-the-agent-browser-demo). +4. Start the [browser worker](#browser-worker) and configure a model, then ask **“Check out Hacker News for cool stuff”** or **“Summarize copilotkit.ai”**. Follow the browser inline and use **Take control** to open its session. For a model-free version of this flow, follow the [AI Mock demo setup](docs/DEMO.md#run-the-agent-browser-demo). For iOS or Android, use `pnpm --dir apps/mobile ios` or `pnpm --dir apps/mobile android`. Xcode or Android tooling is required. The PDF reader needs an Expo development build; use [native setup](apps/mobile/README.md). @@ -134,9 +137,9 @@ No hidden retry occurs after an uncertain external write. Review its provider ou ## CopilotKit Rich Threads -Live deployments require `CPK_INTELLIGENCE_API_KEY` on the API server for CopilotKit Intelligence conversation persistence and replay. Create or select a project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`, set the generated server-only key, and restart the API. The native menu uses `useThreads`; rich tool results link back to saved tasks, documents, and browser sessions. +Every deployment requires `CPK_INTELLIGENCE_API_KEY` on the API server for CopilotKit Intelligence conversation persistence and replay. Create or select a project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`, set the generated server-only key, and restart the API. The native menu uses `useThreads`; rich tool results link back to saved tasks, documents, and browser sessions. -Sample mode can leave the key unset and keeps one conversation in the local database. Intelligence is a separate service and is not included in this repository's MIT license. No project key is shipped. [Configuration and validation boundaries](docs/RICH-THREADS.md). +Intelligence is a separate service and is not included in this repository's MIT license. No project key is shipped. [Configuration and validation boundaries](docs/RICH-THREADS.md). ## Architecture @@ -144,7 +147,7 @@ Sample mode can leave the key unset and keeps one conversation in the local data flowchart TD Client[Expo / React Native / Web] -->|AG-UI and authenticated API| API[Hono + CopilotKit runtime] API --> Tasks[Durable task worker] - API --> Threads[CopilotKit Intelligence required in live mode] + API --> Threads[CopilotKit Intelligence required in every mode] API --> Store[(PGlite or PostgreSQL)] Tasks --> Store Tasks --> Review[Stored action review] diff --git a/SECURITY.md b/SECURITY.md index 9f6badd92..a2a24909e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,4 +28,4 @@ Docker shares its host kernel and does not provide a full VM or a hostile-tenant A proposal is bound to the account, reviewed content, and applicable provider version. The server requires a recorded approval before dispatching a send or calendar change. An uncertain network outcome is retained for reconciliation. Cancellation stops later task steps; a provider request already in flight may still finish. -No provider keys, personal data, or third-party logins are needed for the sample walkthrough or CI. CopilotKit Intelligence and any configured model/provider operate under their own terms and data policies. +A server-only CopilotKit Intelligence project key is needed for the sample walkthrough. CI uses synthetic keys and mocked Intelligence boundaries. No provider keys, personal data, or third-party logins are needed for CI. CopilotKit Intelligence and any configured model/provider operate under their own terms and data policies. diff --git a/apps/computer/smoke.test.ts b/apps/computer/smoke.test.ts index 422c1b38f..fe361ad56 100644 --- a/apps/computer/smoke.test.ts +++ b/apps/computer/smoke.test.ts @@ -25,6 +25,7 @@ test("real isolated computer executes commands, persists files, bridges PDFs and publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "sample", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost/callback", allowedOrigins: [], computerEnabled: true, diff --git a/apps/server/src/agent.ts b/apps/server/src/agent.ts index 1587a6a90..09ffa90ba 100644 --- a/apps/server/src/agent.ts +++ b/apps/server/src/agent.ts @@ -28,7 +28,7 @@ export function makeRuntime( config: Config, service: AgentService, auth: Auth, - intelligence?: CopilotKitIntelligence, + intelligence: CopilotKitIntelligence, ) { const agents: AgentsFactory = async ({ request }) => ({ default: @@ -49,16 +49,14 @@ export function makeRuntime( await auth.owner(request.headers.get("authorization") ?? undefined), ), }); - const runtime = intelligence - ? new CopilotRuntime({ - agents, - intelligence, - identifyUser: async (request) => ({ - id: await auth.owner(request.headers.get("authorization") ?? undefined), - name: "OpenMuse user", - }), - generateThreadNames: false, - }) - : new CopilotRuntime({ agents }); + const runtime = new CopilotRuntime({ + agents, + intelligence, + identifyUser: async (request) => ({ + id: await auth.owner(request.headers.get("authorization") ?? undefined), + name: "OpenMuse user", + }), + generateThreadNames: false, + }); return createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" }); } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2ccd0479c..a162d2992 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -12,7 +12,7 @@ import { createAuth } from "./auth.ts"; import { BrowserService } from "./browser.ts"; import { ComputerService, type DockerRunner } from "./computer.ts"; import { computerRoutes } from "./computer-routes.ts"; -import type { Config } from "./config.ts"; +import { assertApiDeploymentConfig, type Config } from "./config.ts"; import type { Store } from "./db.ts"; import { agentRoutes } from "./engine/routes.ts"; import { AgentService } from "./engine/service.ts"; @@ -26,6 +26,7 @@ export async function createApp( config: Config, options: { docker?: DockerRunner } = {}, ) { + assertApiDeploymentConfig(config); const auth = await createAuth(db, config), files = new Files(db, config, auth), google = new GoogleAuth(db, config), @@ -40,9 +41,7 @@ export async function createApp( const browser = new BrowserService(db, config, auth, files); const computer = new ComputerService(db, config, options.docker); const agent = new AgentService(db, config, workspace, files, actions, browser, computer); - const intelligence = config.intelligenceApiKey - ? new CopilotKitIntelligence({ apiKey: config.intelligenceApiKey }) - : undefined; + const intelligence = new CopilotKitIntelligence({ apiKey: config.intelligenceApiKey }); const runtime = makeRuntime(config, agent, auth, intelligence); const app = new Hono<{ Variables: { owner: string } }>(); const origins = new Set([...config.allowedOrigins, new URL(config.publicUrl).origin]); @@ -203,21 +202,19 @@ export async function createApp( }); const main = await db.get<{ threadId: string }>(owner, "conversation-settings", "main"); if (!main) throw new AppError("Main conversation could not be loaded", 503); - if (intelligence) { - try { - await intelligence.getOrCreateThread({ - threadId: main.threadId, - userId: owner, - agentId: "default", - }); - } catch { - throw new AppError( - "Main conversation is unavailable. Check the Rich Threads connection and try again.", - 502, - ); - } + try { + await intelligence.getOrCreateThread({ + threadId: main.threadId, + userId: owner, + agentId: "default", + }); + } catch { + throw new AppError( + "Main conversation is unavailable. Check the Rich Threads connection and try again.", + 502, + ); } - return c.json({ threadId: main.threadId, existing: Boolean(intelligence) }); + return c.json({ threadId: main.threadId, existing: true }); }); app.get("/api/conversation", async (c) => c.json((await db.get(c.get("owner"), "conversations", "default")) ?? { messages: [] }), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 55f8bec00..0b25b3ccc 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,16 +31,25 @@ export interface Config { allowedOrigins: string[]; } -const missingIntelligenceKeyMessage = - "Live mode requires CPK_INTELLIGENCE_API_KEY for durable Rich Threads. " + +export const intelligenceKeyRequiredMessage = + "OpenMuse requires CPK_INTELLIGENCE_API_KEY. " + "Run `npx copilotkit@latest login` and `npx copilotkit@latest project select`, " + "then set the generated server-only key. " + "See https://docs.copilotkit.ai/intelligence/connect-your-runtime"; -export function assertApiDeploymentConfig(config: Config): void { - if (config.mode === "live" && !config.intelligenceApiKey?.trim()) { - throw new Error(missingIntelligenceKeyMessage); - } +export function required(name: string, message: string, value = process.env[name]): string { + if (!value?.trim()) throw new Error(message); + return value.trim(); +} + +export function assertApiDeploymentConfig( + config: Config, +): asserts config is Config & { intelligenceApiKey: string } { + required( + "CPK_INTELLIGENCE_API_KEY", + intelligenceKeyRequiredMessage, + config.intelligenceApiKey ?? "", + ); } export function readConfig(): Config { @@ -67,7 +76,7 @@ export function readConfig(): Config { agentBackend: backend, agentUrl: process.env.AGENT_URL, agentToken: process.env.AGENT_TOKEN, - intelligenceApiKey: process.env.CPK_INTELLIGENCE_API_KEY, + intelligenceApiKey: required("CPK_INTELLIGENCE_API_KEY", intelligenceKeyRequiredMessage), googleClientId: process.env.GOOGLE_CLIENT_ID, googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, googleRedirectUri: `${publicUrl}/api/google/callback`, diff --git a/apps/server/src/demo/entry.ts b/apps/server/src/demo/entry.ts index dd622d1b5..4505e4237 100644 --- a/apps/server/src/demo/entry.ts +++ b/apps/server/src/demo/entry.ts @@ -3,10 +3,12 @@ import { createHash } from "node:crypto"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { intelligenceKeyRequiredMessage, required } from "../config.ts"; import { createStore } from "../db.ts"; import { createDemoModel, demoModel } from "./model.ts"; const root = fileURLToPath(new URL("../../../../", import.meta.url)); +const intelligenceApiKey = required("CPK_INTELLIGENCE_API_KEY", intelligenceKeyRequiredMessage); const dataDir = join(root, "artifacts", "demo", "api"); const runtimeDir = join(root, "artifacts", "demo", "runtime"); const port = Number(process.env.DEMO_API_PORT ?? "8788"); @@ -84,6 +86,7 @@ const api = spawn( process.env.DEMO_ALLOWED_ORIGINS ?? "http://localhost:8081,http://127.0.0.1:8081", DO_NOT_TRACK: "1", COPILOTKIT_TELEMETRY_DISABLED: "true", + CPK_INTELLIGENCE_API_KEY: intelligenceApiKey, }, }, ); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 17c83bb8f..6a355b292 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,10 +1,9 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app.ts"; -import { assertApiDeploymentConfig, readConfig } from "./config.ts"; +import { readConfig } from "./config.ts"; import { createStore } from "./db.ts"; const config = readConfig(); -assertApiDeploymentConfig(config); const db = await createStore({ dataDir: `${config.dataDir}/postgres`, databaseUrl: config.databaseUrl, diff --git a/apps/server/src/workspace.ts b/apps/server/src/workspace.ts index 2b740aa9b..6d5aafd22 100644 --- a/apps/server/src/workspace.ts +++ b/apps/server/src/workspace.ts @@ -323,7 +323,7 @@ export class WorkspaceService { provider: this.config.agentBackend === "sample" ? "sample" : "model", configured: agentConfigured(this.config), openbotConfigured: false, - richThreads: Boolean(this.config.intelligenceApiKey), + richThreads: true, }, }; } diff --git a/docs/DEMO.md b/docs/DEMO.md index 4f42af673..70b060096 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -62,11 +62,13 @@ From the repository root: ```sh pnpm install --frozen-lockfile +npx copilotkit@latest login +npx copilotkit@latest project select pnpm --dir apps/worker exec playwright install chromium pnpm dev:demo ``` -This starts AI Mock, the normal OpenMuse API on port **8788**, and a separate real browser worker on **8791**. Demo files and profiles stay in ignored `artifacts/demo/`. The runner supplies an explicit local environment and does not load the project's private `.env` or provider credentials. The Linux computer is disabled for this focused browser recording. +This starts AI Mock, the normal OpenMuse API on port **8788**, and a separate real browser worker on **8791**. Demo files and profiles stay in ignored `artifacts/demo/`. The runner reads only the Intelligence key from the project's private `.env` and passes it to its isolated API process; it does not pass provider or Google credentials. The Linux computer is disabled for this focused browser recording. Start the app in another terminal: diff --git a/docs/RICH-THREADS.md b/docs/RICH-THREADS.md index f825d9dc5..fff5268d0 100644 --- a/docs/RICH-THREADS.md +++ b/docs/RICH-THREADS.md @@ -4,7 +4,7 @@ OpenMuse uses `@copilotkit/react-native/headless` for its custom native and web ## Rich Threads -`WORKSPACE_MODE=live` requires a CopilotKit Intelligence project key before the API will start. Create or select a project: +Every workspace mode requires a CopilotKit Intelligence project key before the API will start. Create or select a project: ```sh npx copilotkit@latest login @@ -25,7 +25,7 @@ The composer stays editable during replies. Follow-ups enter a visible, removabl Rich tool messages retain task IDs. The renderer fetches current task status, browser previews, PDF links and structured artifacts from the authenticated task endpoint. Expiring file/preview URLs are generated by the server rather than stored in thread messages. New Intelligence conversations never load or overwrite `/api/conversation`. -When the key is unset in sample mode, OpenMuse keeps its existing local conversation store and the menu identifies that mode. Sample history remains available after returning from live mode, but it is not automatically uploaded to Intelligence. Live mode does not fall back to this store: the API fails at startup with the project-selection commands when the key is missing. +The API fails at startup with a missing-key error when the key is unset. Existing local sample history is not automatically uploaded to Intelligence. Automatic thread naming is disabled; conversations can be renamed in the menu. Intelligence is a separately configured service, not bundled with this MIT-licensed application. See [headless threads](https://docs.copilotkit.ai/headless-threads) for the platform lifecycle and hosting options. diff --git a/tests/agent-api.test.ts b/tests/agent-api.test.ts index ee74cfe67..c3b58a34a 100644 --- a/tests/agent-api.test.ts +++ b/tests/agent-api.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, test } from "node:test"; +import { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; import { createApp } from "../apps/server/src/app.ts"; import type { Config } from "../apps/server/src/config.ts"; import { createStore, type Store } from "../apps/server/src/db.ts"; @@ -41,6 +42,7 @@ before(async () => { publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "model", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost:8787/api/google/callback", allowedOrigins: ["http://localhost:8081"], }; @@ -77,19 +79,26 @@ test("agent API requires a session and reports the actual worker state", async ( assert.equal(workspace.identity.tone, "warm"); }); -test("the main Rich Thread survives reopening and concurrent initialization", async () => { +test("the main Rich Thread survives reopening and concurrent initialization", async (t) => { + t.mock.method( + CopilotKitIntelligence.prototype, + "getOrCreateThread", + async (input: Parameters[0]) => ({ + id: input.threadId, + }), + ); assert.equal((await server.app.request("/api/main-thread")).status, 401); const responses = await Promise.all( Array.from({ length: 3 }, () => server.app.request("/api/main-thread", { headers: headers() })), ); const threads = await Promise.all(responses.map((response) => response.json())); assert.ok(threads.every((thread) => thread.threadId === threads[0].threadId)); - assert.equal(threads[0].existing, false); + assert.equal(threads[0].existing, true); const reopened = await ( await server.app.request("/api/main-thread", { headers: headers() }) ).json(); assert.equal(reopened.threadId, threads[0].threadId); - assert.equal(reopened.existing, false); + assert.equal(reopened.existing, true); assert.equal(await db.get("other-user", "conversation-settings", "main"), null); }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 747eb5ea6..d26bc14ea 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -3,27 +3,37 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, test } from "node:test"; +import { EventType } from "@ag-ui/core"; +import { lastValueFrom, toArray } from "rxjs"; import { createApp } from "../apps/server/src/app.ts"; import type { Config } from "../apps/server/src/config.ts"; import { createStore, type Store } from "../apps/server/src/db.ts"; +import { ConversationAgent } from "../apps/server/src/engine/conversation.ts"; +import type { AgentService } from "../apps/server/src/engine/service.ts"; import type { ActionProposal, Artifact, Workspace } from "../packages/domain/src/index.ts"; -let db: Store, app: Awaited>["app"], token: string, directory: string; +let db: Store, + app: Awaited>["app"], + agent: AgentService, + config: Config, + token: string, + directory: string; const headers = () => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" }); before(async () => { directory = await mkdtemp(join(tmpdir(), "openmuse-api-")); db = await createStore(); - const config: Config = { + config = { mode: "sample", port: 8787, host: "127.0.0.1", publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "sample", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost:8787/api/google/callback", allowedOrigins: ["http://localhost:8081"], }; - ({ app } = await createApp(db, config)); + ({ app, agent } = await createApp(db, config)); const response = await app.request("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -156,24 +166,25 @@ test("calendar ranges and complete sample mail threads survive navigation", asyn ); assert.equal(invalid.status, 422); }); -test("CopilotKit runtime streams actual AG-UI events without a provider key", async () => { +function sampleRun(threadId: string, runId: string, messageId: string, content: string) { + return new ConversationAgent(config, agent, "local-user").run({ + threadId, + runId, + messages: [{ id: messageId, role: "user", content }], + tools: [], + context: [], + state: {}, + }); +} + +test("sample agent streams actual AG-UI events without a model key", async () => { const info = await app.request("/api/copilotkit/info", { headers: headers() }); assert.equal(info.status, 200); - const response = await app.request("/api/copilotkit/agent/default/run", { - method: "POST", - headers: headers(), - body: JSON.stringify({ - threadId: "sample-test", - runId: "sample-run", - messages: [{ id: "message1", role: "user", content: "Show my calendar" }], - tools: [], - context: [], - state: {}, - forwardedProps: {}, - }), - }); - assert.equal(response.status, 200); - const stream = await response.text(); + const stream = JSON.stringify( + await lastValueFrom( + sampleRun("sample-test", "sample-run", "message1", "Show my calendar").pipe(toArray()), + ), + ); assert.match(stream, /RUN_STARTED/); assert.match(stream, /TEXT_MESSAGE_CONTENT/); assert.match(stream, /RUN_FINISHED/); @@ -181,28 +192,19 @@ test("CopilotKit runtime streams actual AG-UI events without a provider key", as }); test("guided document delegation streams a rich tool result bound to its saved task", async () => { - const response = await app.request("/api/copilotkit/agent/default/run", { - method: "POST", - headers: headers(), - body: JSON.stringify({ - threadId: "document-thread", - runId: "document-run", - messages: [{ id: "document-request", role: "user", content: "Complete the permission slip" }], - tools: [], - context: [], - state: {}, - forwardedProps: {}, - }), - }); - assert.equal(response.status, 200); - const events = (await response.text()) - .split("\n") - .filter((line) => line.startsWith("data: {")) - .map((line) => JSON.parse(line.slice(6))); - const start = events.find((event) => event.type === "TOOL_CALL_START"); - const result = events.find((event) => event.type === "TOOL_CALL_RESULT"); + const events = await lastValueFrom( + sampleRun( + "document-thread", + "document-run", + "document-request", + "Complete the permission slip", + ).pipe(toArray()), + ); + const start = events.find((event) => event.type === EventType.TOOL_CALL_START); + const result = events.find((event) => event.type === EventType.TOOL_CALL_RESULT); assert.equal(start?.toolCallName, "delegate_task"); assert.equal(result?.toolCallId, start?.toolCallId); + assert.ok(result && typeof result.content === "string"); const { id } = JSON.parse(result.content); const task = await db.get<{ input: { messageId: string }; kind: string }>( "local-user", diff --git a/tests/config.test.ts b/tests/config.test.ts index baa6f4f2c..aab558a12 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -23,24 +23,26 @@ function liveConfig(intelligenceApiKey?: string): Config { } const missingKeyMessage = - "Live mode requires CPK_INTELLIGENCE_API_KEY for durable Rich Threads. " + + "OpenMuse requires CPK_INTELLIGENCE_API_KEY. " + "Run `npx copilotkit@latest login` and `npx copilotkit@latest project select`, " + "then set the generated server-only key. " + "See https://docs.copilotkit.ai/intelligence/connect-your-runtime"; -test("live API configuration rejects a missing or blank Intelligence key", () => { - for (const key of [undefined, "", " \t\n"]) { - assert.throws(() => assertApiDeploymentConfig(liveConfig(key)), { - name: "Error", - message: missingKeyMessage, - }); +test("every API mode rejects a missing or blank Intelligence key", () => { + for (const mode of [sampleConfig, liveConfig()]) { + for (const key of [undefined, "", " \t\n"]) { + assert.throws(() => assertApiDeploymentConfig({ ...mode, intelligenceApiKey: key }), { + name: "Error", + message: missingKeyMessage, + }); + } } }); -test("live API configuration accepts a non-empty Intelligence key", () => { - assert.doesNotThrow(() => assertApiDeploymentConfig(liveConfig("test-project-key-never-sent"))); -}); - -test("sample API configuration remains key-free", () => { - assert.doesNotThrow(() => assertApiDeploymentConfig(sampleConfig)); +test("every API mode accepts a non-empty Intelligence key", () => { + for (const mode of [sampleConfig, liveConfig()]) { + assert.doesNotThrow(() => + assertApiDeploymentConfig({ ...mode, intelligenceApiKey: "test-project-key-never-sent" }), + ); + } }); diff --git a/tests/helpers/browser.ts b/tests/helpers/browser.ts index d7b9146de..41292879e 100644 --- a/tests/helpers/browser.ts +++ b/tests/helpers/browser.ts @@ -36,6 +36,7 @@ export async function browserFixture( publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "sample", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost:8787/api/google/callback", allowedOrigins: [], workerUrl: `http://127.0.0.1:${address.port}`, diff --git a/tests/helpers/computer.ts b/tests/helpers/computer.ts index 380a28c21..63344d6f4 100644 --- a/tests/helpers/computer.ts +++ b/tests/helpers/computer.ts @@ -11,6 +11,7 @@ export const config: Config = { publicUrl: "http://localhost:8787", dataDir: "/unused", agentBackend: "sample", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost/callback", allowedOrigins: [], computerEnabled: true, diff --git a/tests/model-worker.test.ts b/tests/model-worker.test.ts index cc01755f2..ad297dfa7 100644 --- a/tests/model-worker.test.ts +++ b/tests/model-worker.test.ts @@ -43,6 +43,7 @@ test("CopilotKit model worker executes server tools and persists the confirmed o publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "model", + intelligenceApiKey: "test-project-key-never-sent", model: "openai/fixture", googleRedirectUri: "http://localhost:8787/api/google/callback", allowedOrigins: [], diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index 66e2dcc36..404b11abb 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -20,6 +20,7 @@ before(async () => { publicUrl: "http://localhost:8787", dataDir: directory, agentBackend: "sample", + intelligenceApiKey: "test-project-key-never-sent", googleRedirectUri: "http://localhost:8787/api/google/callback", allowedOrigins: [], });