diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index 5db1d7d62..e4d0bc327 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -16,5 +16,6 @@ export type * from './types'; export type { KiloChatEvent, KiloChatEventName, KiloChatEventOf } from './events'; export * from './schemas'; export * from './webhook-schemas'; +export type * from './rpc-types'; export * from './events'; export * from './route-helpers'; diff --git a/packages/kilo-chat/src/rpc-types.ts b/packages/kilo-chat/src/rpc-types.ts new file mode 100644 index 000000000..67c361c66 --- /dev/null +++ b/packages/kilo-chat/src/rpc-types.ts @@ -0,0 +1,51 @@ +// Cross-service RPC contracts exposed by the kilo-chat WorkerEntrypoint. +// +// Producer: services/kilo-chat/src/index.ts (KiloChatService) +// Consumers: any worker with a service binding to kilo-chat +// (e.g. webhook-agent-ingest, kiloclaw) +// +// The kilo-chat producer imports these types directly. Consumers import +// them when declaring their service-binding shape (Cloudflare's wrangler +// types only emit a generic `Service` for service bindings; the precise +// RPC method shape is declared per-consumer alongside the binding). +// +// Keeping the contract in one shared package gives us compile-time drift +// detection: a change here breaks both producer and consumer in the same +// build. + +// ── postMessageAsUser ────────────────────────────────────────────── + +export type PostMessageAsUserCorrelation = { + triggerId?: string; + webhookRequestId?: string; + reason?: string; +}; + +export type PostMessageAsUserParams = { + userId: string; + sandboxId: string; + message: string; + // Origin identifier for diagnostics (e.g. "webhook", "onboarding-warmup"). + // Logged so structured-log queries can attribute new conversations to a + // specific source. + source: string; + // Default true. Pass false to fail the call if the user has never opened + // a chat with this bot. + autoCreateConversation?: boolean; + correlation?: PostMessageAsUserCorrelation; +}; + +export type PostMessageAsUserOk = { + ok: true; + conversationId: string; + messageId: string; + conversationCreated: boolean; +}; + +export type PostMessageAsUserErr = { + ok: false; + code: 'invalid_request' | 'no_conversation' | 'forbidden' | 'internal'; + error: string; +}; + +export type PostMessageAsUserResult = PostMessageAsUserOk | PostMessageAsUserErr; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f6629616..a27c7da5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2353,6 +2353,9 @@ importers: '@kilocode/encryption': specifier: workspace:* version: link:../../packages/encryption + '@kilocode/kilo-chat': + specifier: workspace:* + version: link:../../packages/kilo-chat '@kilocode/worker-utils': specifier: workspace:* version: link:../../packages/worker-utils @@ -17027,7 +17030,7 @@ snapshots: '@storybook/csf': 0.1.13 '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/server-webpack5': 8.5.8(@swc/core@1.15.18)(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@rspack/core' @@ -17051,9 +17054,9 @@ snapshots: '@chromaui/rrweb-snapshot': 2.0.0-alpha.18-noAbsolute '@playwright/test': 1.58.2 '@segment/analytics-node': 2.1.3 - '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/csf': 0.1.13 - '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/server-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 @@ -21982,68 +21985,28 @@ snapshots: '@stitches/core@1.2.8': {} - '@storybook/addon-actions@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/global': 5.0.0 - '@types/uuid': 9.0.8 - dequal: 2.0.3 - polished: 4.3.1 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - uuid: 9.0.1 - optional: true - '@storybook/addon-actions@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - uuid: 9.0.1 - - '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/global': 5.0.0 - memoizerific: 1.11.3 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - optional: true + uuid: 9.0.1 '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - ts-dedent: 2.2.0 - - '@storybook/addon-controls@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/global': 5.0.0 - dequal: 2.0.3 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 - optional: true '@storybook/addon-controls@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - ts-dedent: 2.2.0 - - '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/blocks': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/csf-plugin': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@types/react' - optional: true '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: @@ -22053,7 +22016,7 @@ snapshots: '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -22071,23 +22034,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/addon-actions': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-backgrounds': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-controls': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-docs': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-highlight': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-measure': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-outline': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@types/react' - optional: true - '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/addon-actions': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22099,21 +22045,15 @@ snapshots: '@storybook/addon-outline': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/addon-highlight@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@storybook/addon-links@9.1.20(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: @@ -22122,30 +22062,16 @@ snapshots: optionalDependencies: react: 19.2.4 - '@storybook/addon-measure@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - tiny-invariant: 1.3.3 - optional: true - '@storybook/addon-measure@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - tiny-invariant: 1.3.3 - - '@storybook/addon-outline@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@storybook/global': 5.0.0 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - optional: true + tiny-invariant: 1.3.3 '@storybook/addon-outline@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 '@storybook/addon-themes@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': @@ -22153,42 +22079,20 @@ snapshots: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 - '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - - '@storybook/addon-viewport@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - memoizerific: 1.11.3 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true '@storybook/addon-viewport@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: memoizerific: 1.11.3 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - - '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/csf': 0.1.12 - '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optional: true '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/csf': 0.1.12 '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 optionalDependencies: react: 19.2.4 @@ -22210,7 +22114,7 @@ snapshots: path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.4 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) style-loader: 3.3.4(webpack@5.105.4(@swc/core@1.15.18)(esbuild@0.27.4)) terser-webpack-plugin: 5.4.0(@swc/core@1.15.18)(esbuild@0.27.4)(webpack@5.105.4(@swc/core@1.15.18)(esbuild@0.27.4)) ts-dedent: 2.2.0 @@ -22232,7 +22136,7 @@ snapshots: '@storybook/builder-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@types/semver': 7.7.1 browser-assert: 1.2.1 case-sensitive-paths-webpack-plugin: 2.4.0 @@ -22294,24 +22198,13 @@ snapshots: - uglify-js - webpack-cli - '@storybook/components@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/components@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - - '@storybook/core-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - optional: true '@storybook/core-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 '@storybook/core-webpack@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': @@ -22319,15 +22212,9 @@ snapshots: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 - '@storybook/csf-plugin@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - unplugin: 1.16.1 - optional: true - '@storybook/csf-plugin@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) unplugin: 1.16.1 '@storybook/csf-plugin@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': @@ -22350,14 +22237,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/manager-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/manager-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@storybook/nextjs@9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4))': dependencies: @@ -22443,35 +22325,19 @@ snapshots: - uglify-js - webpack-cli - '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/global': 5.0.0 - '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - safe-identifier: 0.4.2 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - yaml-loader: 0.8.1 - optional: true - '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/global': 5.0.0 '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) safe-identifier: 0.4.2 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 yaml-loader: 0.8.1 - '@storybook/preview-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/preview-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.4))': dependencies: @@ -22487,18 +22353,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@storybook/react-dom-shim@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: @@ -22521,7 +22380,7 @@ snapshots: '@storybook/builder-webpack5': 8.5.8(@swc/core@1.15.18)(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3) '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -22533,8 +22392,8 @@ snapshots: '@storybook/server-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3)': dependencies: '@storybook/builder-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3) - '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - '@rspack/core' @@ -22545,19 +22404,6 @@ snapshots: - webpack-cli optional: true - '@storybook/server@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - '@storybook/components': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/csf': 0.1.12 - '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/preview-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - '@storybook/theming': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - ts-dedent: 2.2.0 - yaml: 2.8.4 - optional: true - '@storybook/server@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/components': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22566,7 +22412,7 @@ snapshots: '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/preview-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/theming': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 yaml: 2.8.4 @@ -22600,14 +22446,9 @@ snapshots: - supports-color - ts-node - '@storybook/theming@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': - dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - optional: true - '@storybook/theming@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) '@streamparser/json@0.0.22': {} @@ -30696,28 +30537,6 @@ snapshots: stoppable@1.1.0: {} - storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6): - dependencies: - '@storybook/global': 5.0.0 - '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@8.0.10(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - '@vitest/spy': 3.2.4 - better-opn: 3.0.2 - esbuild: 0.27.4 - esbuild-register: 3.6.0(esbuild@0.27.4) - recast: 0.23.11 - semver: 7.7.4 - ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - '@testing-library/dom' - - bufferutil - - msw - - supports-color - - utf-8-validate - - vite - storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@storybook/global': 5.0.0 diff --git a/services/kilo-chat/src/__tests__/post-message-as-user.test.ts b/services/kilo-chat/src/__tests__/post-message-as-user.test.ts new file mode 100644 index 000000000..9155f3855 --- /dev/null +++ b/services/kilo-chat/src/__tests__/post-message-as-user.test.ts @@ -0,0 +1,219 @@ +import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; +import { describe, it, expect, vi } from 'vitest'; +import { + postMessageAsUser, + type PostMessageAsUserParams, + type PostMessageAsUserResult, +} from '../services/post-message-as-user'; + +/** Map of userId → set of sandbox IDs they own. */ +const ownershipMap = new Map>(); + +vi.mock('../services/sandbox-ownership', () => ({ + userOwnsSandbox: async (_env: Env, userId: string, sandboxId: string) => + ownershipMap.get(userId)?.has(sandboxId) ?? false, +})); + +vi.mock('../services/user-lookup', () => ({ + resolveUserDisplayInfo: async () => new Map(), +})); + +function grantSandbox(userId: string, sandboxId: string) { + if (!ownershipMap.has(userId)) ownershipMap.set(userId, new Set()); + ownershipMap.get(userId)!.add(sandboxId); +} + +function makeEnv(): Env { + return { + ...env, + EVENT_SERVICE: { + fetch: env.EVENT_SERVICE.fetch.bind(env.EVENT_SERVICE), + connect: env.EVENT_SERVICE.connect.bind(env.EVENT_SERVICE), + pushEvent: async () => true, + }, + } satisfies Env; +} + +// Build a real ExecutionContext, run the callee, then drain pending +// waitUntil work before asserting so isolated-storage cleanup between tests +// does not race. Mirrors the pattern in helpers.ts. +async function runPost( + testEnv: Env, + params: PostMessageAsUserParams +): Promise { + const ctx = createExecutionContext(); + const result = await postMessageAsUser(testEnv, { waitUntil: p => ctx.waitUntil(p) }, params); + await waitOnExecutionContext(ctx); + return result; +} + +describe('postMessageAsUser', () => { + it('auto-creates a conversation on first delivery and posts the message', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + message: 'webhook payload arrived', + source: 'webhook', + autoCreateConversation: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.conversationCreated).toBe(true); + expect(result.conversationId).toBeTruthy(); + expect(result.messageId).toBeTruthy(); + }); + + it('reuses the existing conversation on subsequent deliveries', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + const testEnv = makeEnv(); + + const first = await runPost(testEnv, { + userId, + sandboxId, + message: 'first', + source: 'webhook', + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.conversationCreated).toBe(true); + + const second = await runPost(testEnv, { + userId, + sandboxId, + message: 'second', + source: 'webhook', + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.conversationCreated).toBe(false); + expect(second.conversationId).toBe(first.conversationId); + }); + + it('returns no_conversation when autoCreateConversation is false and none exists', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + message: 'should fail', + source: 'onboarding-warmup', + autoCreateConversation: false, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('no_conversation'); + }); + + it('surfaces forbidden when the user does not own the sandbox', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + // intentionally NOT granting ownership + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + message: 'should be forbidden', + source: 'webhook', + autoCreateConversation: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('forbidden'); + }); + + it('rejects forbidden even when a stale conversation already exists', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + const testEnv = makeEnv(); + + // Seed: grant ownership long enough to create a conversation. + grantSandbox(userId, sandboxId); + const seed = await runPost(testEnv, { + userId, + sandboxId, + message: 'seed message', + source: 'webhook', + }); + expect(seed.ok).toBe(true); + + // Revoke ownership (simulates instance destroyed / reassigned). The + // conversation still exists in MEMBERSHIP_DO, so the existing-conversation + // path runs without the create-time ownership check. + ownershipMap.get(userId)?.delete(sandboxId); + + const result = await runPost(testEnv, { + userId, + sandboxId, + message: 'should now be forbidden', + source: 'webhook', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('forbidden'); + }); + + it('rejects empty messages with invalid_request before any side effects', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + message: ' ', + source: 'webhook', + autoCreateConversation: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('invalid_request'); + }); + + it('rejects messages exceeding the chat content limit with invalid_request', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + // Larger than MESSAGE_TEXT_MAX_CHARS (8000) so the public schema rejects it. + message: 'x'.repeat(9000), + source: 'webhook', + autoCreateConversation: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('invalid_request'); + }); + + it('accepts correlation metadata without throwing', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + + const result = await runPost(makeEnv(), { + userId, + sandboxId, + message: 'with correlation', + source: 'webhook', + correlation: { triggerId: 'trig-1', webhookRequestId: 'req-1' }, + }); + + expect(result.ok).toBe(true); + }); +}); diff --git a/services/kilo-chat/src/index.ts b/services/kilo-chat/src/index.ts index bcd198ec7..cee05330f 100644 --- a/services/kilo-chat/src/index.ts +++ b/services/kilo-chat/src/index.ts @@ -5,7 +5,7 @@ import { withDORetry } from '@kilocode/worker-utils'; import { cors } from 'hono/cors'; import { useWorkersLogger } from 'workers-tagged-logger'; import type { MiddlewareHandler } from 'hono'; -import { logger } from './util/logger'; +import { logger, withLogTags } from './util/logger'; import { formatError } from '@kilocode/worker-utils'; import { authMiddleware } from './auth'; import { botAuthMiddleware } from './auth-bot'; @@ -25,6 +25,11 @@ import { } from './routes/handler'; import { registerBotRoutes } from './routes/bot-messages'; import { registerSandboxReadRoutes } from './routes/sandbox-reads'; +import { + postMessageAsUser, + type PostMessageAsUserParams, + type PostMessageAsUserResult, +} from './services/post-message-as-user'; export { MembershipDO } from './do/membership-do'; export { ConversationDO } from './do/conversation-do'; export { SandboxStatusDO } from './do/sandbox-status-do'; @@ -99,8 +104,34 @@ export class KiloChatService extends WorkerEntrypoint { return app.fetch(request, this.env, this.ctx); } + /** + * Internal RPC: post a message into the user-bot conversation on behalf + * of the user. Used by webhook-agent-ingest for webhook-to-chat delivery + * and reusable for other internal flows (e.g. onboarding warmup). + * + * Auto-creates the conversation by default if the user has never opened + * one. Pass `autoCreateConversation: false` to fail when none exists. + */ + async postMessageAsUser(params: PostMessageAsUserParams): Promise { + // Wrap in withLogTags so logger.setTags inside the helper actually + // propagates. Without an active context (HTTP middleware or wrap), + // setTags is a silent no-op for AsyncLocalStorage-backed loggers. + return await withLogTags({ source: 'kilo-chat-rpc:postMessageAsUser' }, () => + postMessageAsUser(this.env, { waitUntil: p => this.ctx.waitUntil(p) }, params) + ); + } + async destroySandboxData( sandboxId: string + ): Promise<{ ok: boolean; conversationsDeleted: number; failedConversations: string[] }> { + return await withLogTags({ source: 'kilo-chat-rpc:destroySandboxData' }, () => { + logger.setTags({ sandboxId }); + return this.destroySandboxDataImpl(sandboxId); + }); + } + + private async destroySandboxDataImpl( + sandboxId: string ): Promise<{ ok: boolean; conversationsDeleted: number; failedConversations: string[] }> { const botId = `bot:kiloclaw:${sandboxId}`; // Discover all conversations for this sandbox, paginating through all results. diff --git a/services/kilo-chat/src/services/post-message-as-user.ts b/services/kilo-chat/src/services/post-message-as-user.ts new file mode 100644 index 000000000..2c1705d43 --- /dev/null +++ b/services/kilo-chat/src/services/post-message-as-user.ts @@ -0,0 +1,175 @@ +// Internal RPC primitive: post a message into the user-bot conversation +// on behalf of the user, from a trusted service-binding caller. +// +// Used by webhook-agent-ingest for webhook-to-chat delivery (replacing the +// deleted `/api/platform/send-chat-message` route). Designed to also serve +// future flows like onboarding warmup that want to post a first message +// from the user's identity before the user opens the chat UI. + +import { logger } from '../util/logger'; +import { createMessageFor, type DeferCtx } from './messages'; +import { createConversationFor } from './conversations'; +import { userOwnsSandbox } from './sandbox-ownership'; +import { withDORetry } from '@kilocode/worker-utils'; +import { + textBlockSchema, + type PostMessageAsUserParams, + type PostMessageAsUserResult, +} from '@kilocode/kilo-chat'; + +// Re-export the shared RPC contract types so callers within this worker +// can import them from a single place alongside the implementation. +export type { + PostMessageAsUserCorrelation, + PostMessageAsUserParams, + PostMessageAsUserOk, + PostMessageAsUserErr, + PostMessageAsUserResult, +} from '@kilocode/kilo-chat'; + +export async function postMessageAsUser( + env: Env, + ctx: DeferCtx, + params: PostMessageAsUserParams +): Promise { + const { userId, sandboxId, message, source, autoCreateConversation = true, correlation } = params; + + logger.setTags({ sandboxId, callerId: userId }); + + // Validate the message body up front against the same schema the public + // createMessage HTTP route enforces. Webhook payloads can be up to 256KB + // and prompt templates may interpolate them verbatim, so without this + // check the RPC would persist messages that exceed the chat content + // limits and bypass the trim/non-empty rules. Failing here also avoids + // creating a brand-new conversation for an invalid message. + const validatedTextBlock = textBlockSchema.safeParse({ type: 'text', text: message }); + if (!validatedTextBlock.success) { + logger.warn('postMessageAsUser: invalid message content', { + source, + issues: validatedTextBlock.error.issues, + ...correlation, + }); + return { + ok: false, + code: 'invalid_request', + error: 'Message is empty or exceeds the maximum chat message length', + }; + } + + // Unconditional ownership check. createConversationFor would catch this on + // the create path, but a stale or cross-user (userId, sandboxId) pair with + // a pre-existing conversation would otherwise post successfully — the + // ConversationDO membership check is not equivalent to current sandbox + // ownership. Run it here so every internal caller gets the same guard. + const owns = await userOwnsSandbox(env, userId, sandboxId); + if (!owns) { + logger.warn('postMessageAsUser: caller does not own sandbox', { + source, + ...correlation, + }); + return { + ok: false, + code: 'forbidden', + error: 'You do not have access to this sandbox', + }; + } + + // Known concurrency limitation: the find-then-create sequence is not + // atomic across the user's MembershipDO and the new ConversationDO. If + // two RPC calls for the same (userId, sandboxId) arrive before any + // conversation exists, both can observe `existingConversationId === null` + // and both can call createConversationFor, producing two parallel + // conversations. Subsequent deliveries route into whichever the + // listConversations({ limit: 1 }) query returns first, which can flap. + // The proper fix is to push find-or-claim into the user's MembershipDO + // (single-threaded execution gives atomicity) and have the caller + // initialize the ConversationDO with the pre-claimed id. Skipped here + // because real-world concurrent first-deliveries per (user, bot) are + // rare: webhook triggers fire serially per trigger, and a user with + // multiple triggers pointing at the same bot would only race on the very + // first delivery across all of them. + const existingConversationId = await findUserBotConversation(env, userId, sandboxId); + + let conversationId: string; + let conversationCreated = false; + if (existingConversationId) { + conversationId = existingConversationId; + } else if (autoCreateConversation) { + const created = await createConversationFor(env, userId, { sandboxId }); + if (!created.ok) { + logger.warn('postMessageAsUser: failed to create conversation', { + source, + code: created.code, + error: created.error, + ...correlation, + }); + return { ok: false, code: created.code, error: created.error }; + } + conversationId = created.conversationId; + conversationCreated = true; + } else { + logger.info('postMessageAsUser: no conversation and auto-create disabled', { + source, + ...correlation, + }); + return { + ok: false, + code: 'no_conversation', + error: 'No conversation between user and bot, and autoCreateConversation is false', + }; + } + + const result = await createMessageFor( + env, + userId, + { + conversationId, + content: [validatedTextBlock.data], + }, + ctx + ); + + if (!result.ok) { + logger.error('postMessageAsUser: createMessageFor failed', { + source, + conversationId, + conversationCreated, + code: result.code, + error: result.error, + ...correlation, + }); + return { ok: false, code: result.code, error: result.error }; + } + + logger.info('postMessageAsUser: delivered', { + source, + conversationId, + conversationCreated, + messageId: result.messageId, + ...correlation, + }); + + return { + ok: true, + conversationId, + messageId: result.messageId, + conversationCreated, + }; +} + +// Look up the user's existing conversation with the given sandbox's bot. +// Returns the most-recently-active conversation id, or null if the user +// has none. The MembershipDO is keyed on user id; listConversations +// already supports a sandbox filter. +async function findUserBotConversation( + env: Env, + userId: string, + sandboxId: string +): Promise { + const result = await withDORetry( + () => env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(userId)), + stub => stub.listConversations({ sandboxId, limit: 1, cursor: null }), + 'MembershipDO.listConversations' + ); + return result.conversations[0]?.conversationId ?? null; +} diff --git a/services/webhook-agent-ingest/package.json b/services/webhook-agent-ingest/package.json index 63b8faf92..1aea26eab 100644 --- a/services/webhook-agent-ingest/package.json +++ b/services/webhook-agent-ingest/package.json @@ -23,6 +23,7 @@ "dependencies": { "@kilocode/db": "workspace:*", "@kilocode/encryption": "workspace:*", + "@kilocode/kilo-chat": "workspace:*", "@kilocode/worker-utils": "workspace:*", "croner": "^10.0.1", "drizzle-orm": "catalog:", diff --git a/services/webhook-agent-ingest/src/db/queries.ts b/services/webhook-agent-ingest/src/db/queries.ts index 5dc4b47bd..45b9f93ab 100644 --- a/services/webhook-agent-ingest/src/db/queries.ts +++ b/services/webhook-agent-ingest/src/db/queries.ts @@ -1,5 +1,10 @@ import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; -import { kilocode_users, organizations, organization_memberships } from '@kilocode/db/schema'; +import { + kilocode_users, + kiloclaw_instances, + organizations, + organization_memberships, +} from '@kilocode/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; export { getWorkerDb, type WorkerDb }; @@ -45,6 +50,37 @@ function generateBotStripeCustomerId(): string { .join('')}`; } +/** + * Resolve a kiloclaw_instances.id to its sandbox_id, returning null if the + * instance is missing, destroyed, or not owned by the given user. Used by + * the webhook-to-chat delivery path to translate a stored trigger config + * (which holds the instance UUID) into the sandboxId expected by kilo-chat. + * + * The userId filter is defense-in-depth: kilo-chat enforces ownership on + * the post path too, but scoping here means a stale or cross-user + * instanceId returns a clean "instance not found" instead of resolving to + * a different user's sandbox and failing downstream with a misleading + * forbidden. + */ +export async function findActiveSandboxIdForInstance( + db: WorkerDb, + instanceId: string, + userId: string +): Promise { + const rows = await db + .select({ sandbox_id: kiloclaw_instances.sandbox_id }) + .from(kiloclaw_instances) + .where( + and( + eq(kiloclaw_instances.id, instanceId), + eq(kiloclaw_instances.user_id, userId), + isNull(kiloclaw_instances.destroyed_at) + ) + ) + .limit(1); + return rows[0]?.sandbox_id ?? null; +} + export async function findUserForToken(db: WorkerDb, userId: string): Promise { const rows = await db .select({ diff --git a/services/webhook-agent-ingest/src/kilo-chat-binding.ts b/services/webhook-agent-ingest/src/kilo-chat-binding.ts new file mode 100644 index 000000000..6699e61d6 --- /dev/null +++ b/services/webhook-agent-ingest/src/kilo-chat-binding.ts @@ -0,0 +1,23 @@ +/** + * KILO_CHAT service-binding shape. + * + * The RPC contract types live in `@kilocode/kilo-chat` (rpc-types.ts) so + * producer (services/kilo-chat) and consumers (this worker, others) share + * one source of truth. wrangler-generated types only emit a generic + * `Service` for service bindings, which is why we still need to declare + * the local binding shape here and cast at the call site. + */ + +import type { PostMessageAsUserParams, PostMessageAsUserResult } from '@kilocode/kilo-chat'; + +export type KiloChatBinding = Fetcher & { + postMessageAsUser(params: PostMessageAsUserParams): Promise; +}; + +/** + * Cast helper. Centralizes the `as KiloChatBinding` cast so call sites + * stay clean. + */ +export function getKiloChat(env: { KILO_CHAT: unknown }): KiloChatBinding { + return env.KILO_CHAT as KiloChatBinding; +} diff --git a/services/webhook-agent-ingest/src/queue-consumer.test.ts b/services/webhook-agent-ingest/src/queue-consumer.test.ts new file mode 100644 index 000000000..94e64a4a4 --- /dev/null +++ b/services/webhook-agent-ingest/src/queue-consumer.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { PostMessageAsUserParams, PostMessageAsUserResult } from '@kilocode/kilo-chat'; + +// Mock module-level imports before importing the unit under test. These mocks +// stand in for: DB lookup of sandbox id, the kilo-chat service binding cast +// helper, and the durable-object retry wrapper. Logger and prompt-template +// are left unmocked because they are pure and side-effect-free. +const mockFindActiveSandboxIdForInstance = + vi.fn<(db: unknown, instanceId: string, userId: string) => Promise>(); +const mockPostMessageAsUser = + vi.fn<(params: PostMessageAsUserParams) => Promise>(); + +vi.mock('./db/queries', () => ({ + findActiveSandboxIdForInstance: (db: unknown, instanceId: string, userId: string) => + mockFindActiveSandboxIdForInstance(db, instanceId, userId), + getWorkerDb: () => ({}), +})); + +vi.mock('./kilo-chat-binding', () => ({ + getKiloChat: () => ({ + postMessageAsUser: (params: PostMessageAsUserParams) => mockPostMessageAsUser(params), + }), +})); + +// withDORetry: run the operation once with the stub. Tests do not exercise +// retry behaviour for these branches; they verify status-flip invariants. +vi.mock('./util/do-retry', () => ({ + withDORetry: async ( + getStub: () => Stub, + op: (stub: Stub) => Promise, + _label: string + ): Promise => op(getStub()), +})); + +import { processKiloclawChatMessage } from './queue-consumer'; + +type UpdateCall = { requestId: string; patch: Record }; + +function makeStub() { + const updateCalls: UpdateCall[] = []; + const stub = { + updateRequest: vi.fn(async (requestId: string, patch: Record) => { + updateCalls.push({ requestId, patch }); + }), + }; + return { stub: stub as unknown as DurableObjectStub, updateCalls }; +} + +function makeWebhook() { + return { + namespace: 'user/user-1', + triggerId: 'trigger-1', + requestId: 'req-1', + }; +} + +function makeRequest(processStatus = 'captured') { + return { + body: '{}', + method: 'POST', + path: '/inbound/user/user-1/trigger-1', + headers: { 'content-type': 'application/json' }, + queryString: null, + sourceIp: null, + timestamp: '2026-05-08T12:00:00Z', + processStatus, + }; +} + +function makeTriggerConfig(overrides: Record = {}) { + return { + triggerId: 'trigger-1', + userId: 'user-1', + orgId: null, + targetType: 'kiloclaw_chat', + kiloclawInstanceId: 'instance-uuid-1', + promptTemplate: 'Webhook says: {{body}}', + isActive: true, + profileId: null, + githubRepo: null, + activationMode: 'always_on', + cronExpression: null, + cronTimezone: 'UTC', + ...overrides, + } as unknown as Parameters[3]; +} + +function makeEnv() { + return { + HYPERDRIVE: { connectionString: 'postgres://test' }, + KILO_CHAT: {} as unknown, + } as unknown as Env; +} + +describe('processKiloclawChatMessage', () => { + beforeEach(() => { + mockFindActiveSandboxIdForInstance.mockReset(); + mockPostMessageAsUser.mockReset(); + }); + + it('flips status to failed when the instance lookup returns null', async () => { + mockFindActiveSandboxIdForInstance.mockResolvedValue(null); + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig(), + makeEnv() + ); + + // No inprogress mark because the lookup failed before that step. + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'failed' }); + expect(updateCalls[0].patch.error_message).toContain('instance not found or destroyed'); + expect(mockPostMessageAsUser).not.toHaveBeenCalled(); + }); + + it('flips inprogress → failed when the kilo-chat RPC throws', async () => { + mockFindActiveSandboxIdForInstance.mockResolvedValue('sandbox-1'); + mockPostMessageAsUser.mockRejectedValue(new Error('service binding outage')); + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig(), + makeEnv() + ); + + // Two updates: inprogress, then failed (the critical invariant). + expect(updateCalls).toHaveLength(2); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'inprogress' }); + expect(updateCalls[1].patch).toMatchObject({ process_status: 'failed' }); + expect(updateCalls[1].patch.error_message).toContain('service binding outage'); + }); + + it('flips inprogress → failed when the kilo-chat RPC returns ok: false', async () => { + mockFindActiveSandboxIdForInstance.mockResolvedValue('sandbox-1'); + const errResult: PostMessageAsUserResult = { + ok: false, + code: 'forbidden', + error: 'You do not have access to this sandbox', + }; + mockPostMessageAsUser.mockResolvedValue(errResult); + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig(), + makeEnv() + ); + + expect(updateCalls).toHaveLength(2); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'inprogress' }); + expect(updateCalls[1].patch).toMatchObject({ process_status: 'failed' }); + expect(updateCalls[1].patch.error_message).toContain('You do not have access'); + }); + + it('flips inprogress → success and forwards correlation on the happy path', async () => { + mockFindActiveSandboxIdForInstance.mockResolvedValue('sandbox-1'); + const okResult: PostMessageAsUserResult = { + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: true, + }; + mockPostMessageAsUser.mockResolvedValue(okResult); + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig(), + makeEnv() + ); + + expect(updateCalls).toHaveLength(2); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'inprogress' }); + expect(updateCalls[1].patch).toMatchObject({ process_status: 'success' }); + + // Source tag and correlation are passed through for log attribution. + expect(mockPostMessageAsUser).toHaveBeenCalledTimes(1); + const args = mockPostMessageAsUser.mock.calls[0][0]; + expect(args.source).toBe('webhook'); + expect(args.correlation).toEqual({ + triggerId: 'trigger-1', + webhookRequestId: 'req-1', + }); + expect(args.userId).toBe('user-1'); + expect(args.sandboxId).toBe('sandbox-1'); + expect(args.autoCreateConversation).toBe(true); + }); + + it('skips when the request was already processed (idempotency guard)', async () => { + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest('success'), + makeTriggerConfig(), + makeEnv() + ); + + expect(updateCalls).toHaveLength(0); + expect(mockFindActiveSandboxIdForInstance).not.toHaveBeenCalled(); + expect(mockPostMessageAsUser).not.toHaveBeenCalled(); + }); + + it('fails fast when the trigger has no kiloclawInstanceId configured', async () => { + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig({ kiloclawInstanceId: null }), + makeEnv() + ); + + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'failed' }); + expect(updateCalls[0].patch.error_message).toContain('instance ID not configured'); + expect(mockFindActiveSandboxIdForInstance).not.toHaveBeenCalled(); + }); + + it('rejects org-scoped triggers (kiloclaw chat is user-scoped only)', async () => { + const { stub, updateCalls } = makeStub(); + + await processKiloclawChatMessage( + stub, + makeWebhook(), + makeRequest(), + makeTriggerConfig({ userId: null, orgId: 'org-1' }), + makeEnv() + ); + + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].patch).toMatchObject({ process_status: 'failed' }); + expect(updateCalls[0].patch.error_message).toContain('user-scoped'); + expect(mockFindActiveSandboxIdForInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/services/webhook-agent-ingest/src/queue-consumer.ts b/services/webhook-agent-ingest/src/queue-consumer.ts index e03678f93..5689df134 100644 --- a/services/webhook-agent-ingest/src/queue-consumer.ts +++ b/services/webhook-agent-ingest/src/queue-consumer.ts @@ -5,6 +5,9 @@ import { logger } from './util/logger'; import { withDORetry } from './util/do-retry'; import { getTokenMintingService } from './services/token-minting-service.js'; import { classifyInitiateResponse } from './initiate-response'; +import { findActiveSandboxIdForInstance, getWorkerDb } from './db/queries'; +import { getKiloChat } from './kilo-chat-binding'; +import type { PostMessageAsUserResult } from '@kilocode/kilo-chat'; import { z } from 'zod'; // Token cache TTL: 30 minutes. Token validity is 1 hour, so 30 min gives safety margin. @@ -92,11 +95,13 @@ async function getOrMintToken( /** * Process a webhook message targeting a KiloClaw Chat instance. - * Renders the prompt template with the webhook payload, then calls the - * KiloClaw worker's send-chat-message endpoint. The KiloClaw worker - * handles instance resolution, destroyed check, and Stream Chat delivery. + * Renders the prompt template with the webhook payload, resolves the + * trigger's instanceId to a sandboxId, and delivers the message into the + * user-bot conversation via the kilo-chat service-binding RPC. kilo-chat + * auto-creates the conversation on first delivery so triggers work even + * before the user opens chat for the first time. */ -async function processKiloclawChatMessage( +export async function processKiloclawChatMessage( stub: DurableObjectStub, webhook: WebhookDeliveryMessage, request: { @@ -155,13 +160,26 @@ async function processKiloclawChatMessage( promptLength: renderedPrompt.length, }); - const internalApiSecret = await env.INTERNAL_API_SECRET.get(); + const sandboxId = await findActiveSandboxIdForInstance( + getWorkerDb(env.HYPERDRIVE.connectionString), + triggerConfig.kiloclawInstanceId, + userId + ); + if (!sandboxId) { + await failRequest( + stub, + webhook.requestId, + 'KiloClaw Chat delivery failed: instance not found or destroyed' + ); + return; + } - // Mark as inprogress immediately before the fetch to prevent duplicate delivery on retry. - // This is placed after all preparatory work (template rendering, secret fetch) so that - // failures in those steps leave the status as 'captured' and allow normal retries. - // On retry after this point, the outer guard in processWebhookMessage sees - // processStatus === 'inprogress' without a cloudAgentSessionId and acks the message. + // Mark as inprogress immediately before delivery to prevent duplicate work + // on queue retry. Placed after preparatory work (template render, sandbox + // lookup) so failures in those steps leave the status as 'captured' and + // allow normal retries. On retry after this point, the outer guard in + // processWebhookMessage sees 'inprogress' without a cloudAgentSessionId + // and acks the message. await withDORetry( () => stub, doStub => @@ -172,40 +190,52 @@ async function processKiloclawChatMessage( 'updateRequest' ); - const response = await fetch(`${env.KILOCLAW_API_URL}/api/platform/send-chat-message`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-internal-api-key': internalApiSecret, - }, - body: JSON.stringify({ + // The request is now `inprogress` in the DO. Any path out of this + // function from here on must either flip it to `success` or `failed`, + // because the outer guard in processWebhookMessage skips inprogress + // requests on retry. A thrown RPC error (e.g. service-binding outage, + // an exception inside postMessageAsUser) would otherwise leave the + // request stuck. The inner if-block handles `{ ok: false }`; the + // try/catch handles thrown errors. + let result: PostMessageAsUserResult; + try { + result = await getKiloChat(env).postMessageAsUser({ userId, - instanceId: triggerConfig.kiloclawInstanceId, + sandboxId, message: renderedPrompt, - }), - }); - - if (!response.ok) { - const errorBody = await response.text().catch(() => '(unreadable)'); - let errorMessage: string; - try { - const parsed = JSON.parse(errorBody) as { error?: string }; - errorMessage = parsed.error ?? errorBody; - } catch { - errorMessage = errorBody; - } - logger.error('KiloClaw Chat message delivery failed', { + source: 'webhook', + autoCreateConversation: true, + correlation: { + triggerId: webhook.triggerId, + webhookRequestId: webhook.requestId, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('KiloClaw Chat message delivery threw', { requestId: webhook.requestId, - status: response.status, error: errorMessage, }); await failRequest(stub, webhook.requestId, `KiloClaw Chat delivery failed: ${errorMessage}`); return; } + if (!result.ok) { + logger.error('KiloClaw Chat message delivery failed', { + requestId: webhook.requestId, + code: result.code, + error: result.error, + }); + await failRequest(stub, webhook.requestId, `KiloClaw Chat delivery failed: ${result.error}`); + return; + } + logger.info('KiloClaw Chat message delivered', { requestId: webhook.requestId, kiloclawInstanceId: triggerConfig.kiloclawInstanceId, + conversationId: result.conversationId, + conversationCreated: result.conversationCreated, + messageId: result.messageId, }); await withDORetry( diff --git a/services/webhook-agent-ingest/worker-configuration.d.ts b/services/webhook-agent-ingest/worker-configuration.d.ts index b50b4b9bc..dcbf71bb2 100644 --- a/services/webhook-agent-ingest/worker-configuration.d.ts +++ b/services/webhook-agent-ingest/worker-configuration.d.ts @@ -16,6 +16,7 @@ declare namespace Cloudflare { INTERNAL_API_SECRET: SecretsStoreSecret; NEXTAUTH_SECRET: SecretsStoreSecret; CLOUD_AGENT: Fetcher /* cloud-agent */; + KILO_CHAT: Service /* entrypoint KiloChatService from kilo-chat */; WEBHOOK_DELIVERY_QUEUE: Queue; HYPERDRIVE: Hyperdrive; CF_VERSION_METADATA: WorkerVersionMetadata; diff --git a/services/webhook-agent-ingest/wrangler.jsonc b/services/webhook-agent-ingest/wrangler.jsonc index c5cf909e2..c43139002 100644 --- a/services/webhook-agent-ingest/wrangler.jsonc +++ b/services/webhook-agent-ingest/wrangler.jsonc @@ -40,6 +40,11 @@ "binding": "CLOUD_AGENT", "service": "cloud-agent-next", }, + { + "binding": "KILO_CHAT", + "service": "kilo-chat", + "entrypoint": "KiloChatService", + }, ], // PRODUCTION Queue for webhook delivery "queues": { @@ -133,6 +138,12 @@ "binding": "CLOUD_AGENT", "service": "cloud-agent-next-dev", }, + { + // kilo-chat has no separate dev deployment; kiloclaw does the same. + "binding": "KILO_CHAT", + "service": "kilo-chat", + "entrypoint": "KiloChatService", + }, ], // DEV Queue (separate from production) "queues": {