diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 4feb7b91318..7632b0bea66 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -17,7 +17,7 @@ concurrency: jobs: e2e: name: E2E (${{ matrix.electron }}) - runs-on: macos-14 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -58,7 +58,7 @@ jobs: package-smoke: name: Unsigned package smoke - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d8a9e650d89..17d7e298a5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -49,7 +49,7 @@ permissions: jobs: build-sign-notarize: name: Build, Sign, Notarize - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -167,8 +167,8 @@ jobs: if: ${{ inputs.sign }} run: | DMG="$(ls apps/desktop/release/*.dmg | head -1)" - xcrun stapler validate "$DMG" hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet + xcrun stapler validate /tmp/sim-dmg/*.app spctl --assess --type execute --verbose /tmp/sim-dmg/*.app codesign --verify --deep --strict /tmp/sim-dmg/*.app hdiutil detach /tmp/sim-dmg -quiet diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 16a2dbb25bf..0b4a4d0fe51 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -129,6 +129,13 @@ jobs: - name: Desktop bridge contract audit run: bun run check:desktop-bridge + # The CLI's view of the v2 API is generated from the same Zod contracts + # the routes validate against, so a contract change that skips + # `generate:cli-api` would ship a client describing endpoints the server + # no longer has. + - name: Sim CLI API generation up to date + run: bun run check:cli-api + # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index 25c137aa8ca..b7849a57558 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,6 +15,14 @@ const IMPORTED_PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const IMPORTED_LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} +const IMPORTED_DARK_PALETTE = { + ...TERMINAL_DARK_THEME, + background: '#202020', +} function makeService() { const config = createConfigStore( @@ -191,7 +199,7 @@ describe('desktop settings service', () => { expect(preferences?.browserDownloadDirectory).toBe('/tmp/custom-downloads') }) - it('caches and selects a Terminal or iTerm2 profile', () => { + it('persists a Terminal or iTerm2 profile with appearance-specific palettes', () => { const { config, service } = makeService() const preferences = service.selectTerminalProfile({ @@ -199,6 +207,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(config.get('terminalTheme')).toEqual({ @@ -206,6 +216,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(preferences).toMatchObject({ terminalTheme: { id: 'iterm2:ocean', name: 'Ocean' }, diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index aeaef5a0852..f84b364b40b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { + cloneTerminalSelectedProfile, type DesktopAppearanceTheme, type DesktopNotificationPayload, type DesktopPreferenceKey, @@ -177,12 +178,7 @@ export function createDesktopSettingsService( return read() }, selectTerminalProfile(profile) { - deps.config.set('terminalTheme', { - id: profile.id, - name: profile.name, - source: profile.source, - palette: { ...profile.palette }, - }) + deps.config.set('terminalTheme', cloneTerminalSelectedProfile(profile)) deps.config.flush() return read() }, diff --git a/apps/desktop/src/main/terminal-themes.test.ts b/apps/desktop/src/main/terminal-themes.test.ts index 55538455b0c..4887ea43342 100644 --- a/apps/desktop/src/main/terminal-themes.test.ts +++ b/apps/desktop/src/main/terminal-themes.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { parseTerminalThemeProfiles } from '@/main/terminal-themes' @@ -6,6 +6,10 @@ const PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} function profile(id: string, overrides: Record = {}) { return { @@ -22,10 +26,22 @@ describe('parseTerminalThemeProfiles', () => { expect(parseTerminalThemeProfiles([profile('iterm2:ocean')])).toEqual([profile('iterm2:ocean')]) }) + it('preserves separate iTerm2 light and dark palettes', () => { + const separateProfile = profile('iterm2:ocean', { + lightPalette: LIGHT_PALETTE, + darkPalette: PALETTE, + }) + + expect(parseTerminalThemeProfiles([separateProfile])).toEqual([separateProfile]) + }) + it('drops malformed colors and unsupported applications', () => { expect( parseTerminalThemeProfiles([ profile('bad-color', { palette: { ...PALETTE, background: 'rgb(0, 0, 0)' } }), + profile('bad-mode-color', { + lightPalette: { ...LIGHT_PALETTE, foreground: 'white' }, + }), profile('bad-source', { source: 'warp' }), ]) ).toEqual([]) diff --git a/apps/desktop/src/main/terminal-themes.ts b/apps/desktop/src/main/terminal-themes.ts index eecbe2e631c..dfd26cb60f5 100644 --- a/apps/desktop/src/main/terminal-themes.ts +++ b/apps/desktop/src/main/terminal-themes.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { + cloneTerminalSelectedProfile, isTerminalSelectedProfile, TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME, @@ -86,21 +87,25 @@ function terminalPalette(profile) { return palette } -function itermPalette(profile) { - const background = dictionaryColor(profile['Background Color'], LIGHT_THEME.background) +function itermColor(profile, key, suffix, fallback) { + return dictionaryColor(profile[key + suffix], dictionaryColor(profile[key], fallback)) +} + +function itermPalette(profile, suffix) { + const background = itermColor(profile, 'Background Color', suffix, LIGHT_THEME.background) const dark = isDark(background) const fallback = dark ? DARK_THEME : LIGHT_THEME const palette = { background: background, - foreground: dictionaryColor(profile['Foreground Color'], fallback.foreground), - cursor: dictionaryColor(profile['Cursor Color'], fallback.cursor), - cursorAccent: dictionaryColor(profile['Cursor Text Color'], background), - selectionBackground: dictionaryColor(profile['Selection Color'], fallback.selectionBackground), - selectionForeground: dictionaryColor(profile['Selected Text Color'], fallback.foreground) + foreground: itermColor(profile, 'Foreground Color', suffix, fallback.foreground), + cursor: itermColor(profile, 'Cursor Color', suffix, fallback.cursor), + cursorAccent: itermColor(profile, 'Cursor Text Color', suffix, background), + selectionBackground: itermColor(profile, 'Selection Color', suffix, fallback.selectionBackground), + selectionForeground: itermColor(profile, 'Selected Text Color', suffix, fallback.foreground) } for (let index = 0; index < PALETTE_KEYS.length; index += 1) { const key = PALETTE_KEYS[index] - palette[key] = dictionaryColor(profile['Ansi ' + index + ' Color'], fallback[key]) + palette[key] = itermColor(profile, 'Ansi ' + index + ' Color', suffix, fallback[key]) } return palette } @@ -131,12 +136,18 @@ try { const guid = String(profile.Guid || '') const name = String(profile.Name || '') if (!guid || !name) continue - profiles.push({ + const result = { id: 'iterm2:' + encodeURIComponent(guid), name: name, source: 'iterm2', - palette: itermPalette(profile) - }) + palette: itermPalette(profile, '') + } + const separateColors = profile['Use Separate Colors for Light and Dark Mode'] + if (separateColors === true || separateColors === 1) { + result.lightPalette = itermPalette(profile, ' (Light)') + result.darkPalette = itermPalette(profile, ' (Dark)') + } + profiles.push(result) } } catch (_) {} @@ -151,12 +162,7 @@ export function parseTerminalThemeProfiles(value: unknown): TerminalThemeProfile for (const candidate of value) { if (!isTerminalSelectedProfile(candidate) || seen.has(candidate.id)) continue seen.add(candidate.id) - profiles.push({ - id: candidate.id, - name: candidate.name, - source: candidate.source, - palette: { ...candidate.palette }, - }) + profiles.push(cloneTerminalSelectedProfile(candidate)) } return profiles.sort( (left, right) => left.source.localeCompare(right.source) || left.name.localeCompare(right.name) @@ -180,9 +186,8 @@ async function readTerminalThemeProfiles(): Promise { let cachedProfiles: TerminalThemeProfile[] | null = null let profileLoad: Promise | null = null -/** Reads Terminal.app and iTerm2 profiles once per desktop process. */ +/** Reads current Terminal.app and iTerm2 profiles, coalescing concurrent requests. */ export async function listTerminalThemeProfiles(): Promise { - if (cachedProfiles) return cachedProfiles profileLoad ??= readTerminalThemeProfiles() .then((profiles) => { cachedProfiles = profiles diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0ba00f9fdca..b2ad5db2670 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -25,6 +25,7 @@ import { isNewerVersion, parseSemver, resolveUpdateChannel, + updateCheckIntervalMs, } from '@/main/updater' describe('resolveUpdateChannel', () => { @@ -39,6 +40,17 @@ describe('resolveUpdateChannel', () => { }) }) +describe('updateCheckIntervalMs', () => { + it('checks dev and staging builds every five minutes', () => { + expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000) + }) + + it('checks production builds every thirty minutes', () => { + expect(updateCheckIntervalMs('1.2.3')).toBe(30 * 60 * 1000) + }) +}) + describe('parseSemver', () => { it('parses plain and v-prefixed versions', () => { expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) @@ -262,6 +274,22 @@ describe('initUpdater state machine', () => { vi.mocked(app.getVersion).mockReturnValue('1.0.0') } }) + + it.each([ + ['1.0.1-alpha.7', 5 * 60 * 1000], + ['1.0.1-beta.7', 5 * 60 * 1000], + ['1.0.1', 30 * 60 * 1000], + ])('schedules %s update polling every %i milliseconds', async (version, interval) => { + vi.mocked(app.getVersion).mockReturnValue(version) + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + await createUpdater({ feedAvailable: true }) + expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), interval) + } finally { + intervalSpy.mockRestore() + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) }) function manifest(version: string): string { diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 711875cfdf6..c35d5e9eaec 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -10,7 +10,8 @@ import type { EventRecorder } from '@/main/observability' const logger = createLogger('DesktopUpdater') const INITIAL_CHECK_DELAY_MS = 10_000 -const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +const PRERELEASE_CHECK_INTERVAL_MS = 5 * 60 * 1000 +const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 export type UpdateChannel = 'latest' | 'beta' | 'alpha' @@ -72,6 +73,13 @@ export function resolveUpdateChannel(version: string): UpdateChannel { return 'latest' } +/** Dev/staging shells poll rapidly; production shells use a quieter cadence. */ +export function updateCheckIntervalMs(version: string): number { + return resolveUpdateChannel(version) === 'latest' + ? STABLE_CHECK_INTERVAL_MS + : PRERELEASE_CHECK_INTERVAL_MS +} + interface ParsedSemver { major: number minor: number @@ -263,7 +271,8 @@ interface UpdateEngine { /** * Keeps installed shells current against the per-environment update feed: - * checks on launch and every four hours, and mirrors pipeline state to the + * checks on launch, then every five minutes for dev/staging builds or every + * thirty minutes for production builds, and mirrors pipeline state to the * renderer for the settings update UI and the minimum-shell-version gate. * * Developer-ID-signed builds use electron-updater (background download, @@ -528,7 +537,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } const check = () => engine?.check() setTimeout(check, INITIAL_CHECK_DELAY_MS) - setInterval(check, CHECK_INTERVAL_MS) + setInterval(check, updateCheckIntervalMs(currentVersion)) }) return { diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 2cea955c88e..dfc07313e3d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API — Execution & Usage", - "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "title": "Sim API — Execution, Chat & Usage", + "description": "Run workflows, chat with a workspace, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", "version": "1.0.0", "contact": { "name": "Sim Support", @@ -36,6 +36,14 @@ { "name": "Billing", "description": "Inspect billing status and credit-denominated ledger events" + }, + { + "name": "Chat", + "description": "Chat with a workspace through Mothership" + }, + { + "name": "Workspaces", + "description": "Resolve workspace metadata available to the authenticated credential" } ], "security": [ @@ -1018,6 +1026,685 @@ "parameters": [] } }, + "/api/v2/workspaces/{workspaceId}": { + "get": { + "operationId": "getWorkspace", + "summary": "Get Workspace", + "description": "Resolve a workspace ID to the display metadata available to the authenticated credential. The credential must have read access to the workspace.", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace to resolve." + } + ], + "responses": { + "200": { + "description": "The workspace's display metadata.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "name", + "color", + "logoUrl", + "mode", + "memberCount", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "color": { "type": "string" }, + "logoUrl": { "type": ["string", "null"] }, + "mode": { + "type": "string", + "enum": ["personal", "organization", "grandfathered_shared"] + }, + "memberCount": { "type": "integer", "minimum": 0 }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Product Operations", + "color": "#7C3AED", + "logoUrl": null, + "mode": "organization", + "memberCount": 12, + "createdAt": "2026-08-07T18:00:00.000Z", + "updatedAt": "2026-08-07T18:30:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } + }, + "/api/v2/chats": { + "get": { + "operationId": "listChats", + "summary": "List Sim Chats", + "description": "List a bounded page of the authenticated user's active workspace chats in the same pinned-first, recently-updated order used by the Sim Home UI. This personal history surface requires a personal API key; shared workspace keys cannot read their creator's private chats. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose chats should be listed." + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { "type": "string", "maxLength": 200 }, + "description": "Case-insensitive title substring." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum chats to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of chat summaries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "updatedAt", "pinned", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "updatedAt": { "type": "string", "format": "date-time" }, + "pinned": { "type": "boolean" }, + "active": { "type": "boolean" } + } + } + }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "example": { + "data": [ + { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "updatedAt": "2026-08-07T18:30:00.000Z", + "pinned": true, + "active": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chats/{chatId}": { + "get": { + "operationId": "getChat", + "summary": "Open Sim Chat", + "description": "Load one owned workspace chat as a display-safe user/assistant transcript and mint a fresh opaque continuation token for the requested safety mode. Internal tool payloads, stream IDs, resources, and replay metadata are not exposed. The subsequent chat POST still accepts only the continuation token, never this resource ID.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the chat must belong to." + }, + { + "name": "readOnly", + "in": "query", + "required": false, + "schema": { "type": "boolean", "default": false }, + "description": "Mint a continuation token for the secretless read-only chat mode." + } + ], + "responses": { + "200": { + "description": "The chat transcript and a fresh continuation token.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title", "messages", "continuationToken", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "role", "content", "timestamp"], + "properties": { + "id": { "type": "string" }, + "role": { "type": "string", "enum": ["user", "assistant"] }, + "content": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + }, + "continuationToken": { "type": "string" }, + "active": { "type": "boolean" } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "messages": [ + { + "id": "msg_1", + "role": "user", + "content": "Review the release workflow", + "timestamp": "2026-08-07T18:29:00.000Z" + }, + { + "id": "msg_2", + "role": "assistant", + "content": "The workflow is ready to release.", + "timestamp": "2026-08-07T18:30:00.000Z" + } + ], + "continuationToken": "sim-v2-chat-v1.opaque.refreshed", + "active": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + }, + "patch": { + "operationId": "renameChat", + "summary": "Rename Sim Chat", + "description": "Rename an owned Sim Chat and synchronize the new title with the Sim Home chat list. This private history operation requires a personal API key; shared workspace keys cannot rename a creator's chats.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "title"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace the chat must belong to." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "New chat title. Leading and trailing whitespace is removed." + } + } + }, + "example": { + "workspaceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "title": "Incident investigation" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed chat.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Incident investigation" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chat": { + "post": { + "operationId": "chat", + "summary": "Ask Sim Chat", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "prompt"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace Sim Chat should operate in." + }, + "prompt": { + "type": "string", + "maxLength": 10485760, + "x-maxUtf8Bytes": 10485760, + "description": "The instruction or question for Sim Chat. UTF-8 input is limited to 10 MiB. It may be empty or whitespace only when at least one attachment is present; the server supplies a neutral inspect-the-attachments instruction in that case." + }, + "continuationToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Latest opaque continuation token returned by a prior `session` or `complete` event. Never send a raw chat or conversation ID." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." + }, + "attachments": { + "type": "array", + "maxItems": 5, + "description": "Optional inline attachments, accepted on initial and continuation turns. Decoded aggregate size is limited to 10 MiB. Images and PDFs are limited to 5 MiB each; UTF-8 text is limited to 200 KiB each. Each image may be at most 8192 pixels on either axis and 16,000,000 total pixels; all images in one request may total at most 32,000,000 decoded pixels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "mediaType", "data"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File basename only. Directory separators and control characters are rejected." + }, + "mediaType": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "text/tab-separated-values", + "text/html", + "text/css", + "text/javascript", + "text/typescript", + "text/xml", + "text/yaml", + "application/json", + "application/jsonl", + "application/x-ndjson", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml" + ], + "description": "Declared MIME type. Image and PDF bytes are sniffed; text must decode as UTF-8." + }, + "data": { + "type": "string", + "minLength": 4, + "maxLength": 13981016, + "contentEncoding": "base64", + "description": "Canonical standard base64 bytes. Data URLs and base64url are not accepted." + } + } + } + }, + "contexts": { + "type": "array", + "maxItems": 50, + "description": "Optional identity-bearing workspace resources, skills, and MCP servers to inject for this turn. Resource kinds correspond to `@` tags; `skill` and `mcp` correspond to `/` tags. MCP contexts are ignored for read-only requests and shared workspace API keys.", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "workflowId", "label"], + "properties": { + "kind": { "type": "string", "const": "workflow" }, + "workflowId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "tableId", "label"], + "properties": { + "kind": { "type": "string", "const": "table" }, + "tableId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "fileId", "label"], + "properties": { + "kind": { "type": "string", "const": "file" }, + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "knowledgeId", "label"], + "properties": { + "kind": { "type": "string", "const": "knowledge" }, + "knowledgeId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "executionId", "label"], + "properties": { + "kind": { "type": "string", "const": "logs" }, + "executionId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "skillId", "label"], + "properties": { + "kind": { "type": "string", "const": "skill" }, + "skillId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "serverId", "label"], + "properties": { + "kind": { "type": "string", "const": "mcp" }, + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + ] + } + } + } + }, + "example": { + "workspaceId": "ws_abc123", + "prompt": "Summarize the attached notes and compare them with this workspace.", + "attachments": [ + { + "name": "notes.md", + "mediaType": "text/markdown", + "data": "IyBOb3Rlcwo=" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "A Sim Chat SSE stream.", + "headers": { + "X-RateLimit-Limit": { + "description": "API request bucket capacity.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current bucket.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Reset": { + "description": "When the current API request bucket resets.", + "schema": { "type": "string", "format": "date-time" } + } + }, + "content": { + "text/event-stream": { + "schema": { "type": "string" }, + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "402": { + "$ref": "#/components/responses/V2UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "409": { + "$ref": "#/components/responses/V2Conflict" + }, + "413": { + "$ref": "#/components/responses/V2PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/V2UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "503": { + "$ref": "#/components/responses/V2ServiceUnavailable" + } + } + } + }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -2248,6 +2935,56 @@ } } }, + "V2NotFound": { + "description": "The requested resource does not exist or is not visible to the credential.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Conflict": { + "description": "The chat already has a response in progress. Retry after that response finishes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UsageLimitExceeded": { + "description": "The resolved workspace payer or organization member has reached a usage limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2PayloadTooLarge": { + "description": "The request body or decoded attachment limits were exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UnsupportedMediaType": { + "description": "An attachment media type or its decoded bytes are unsupported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, "V2RateLimited": { "description": "Rate limit exceeded; retry after the window resets.", "content": { @@ -2257,6 +2994,16 @@ } } } + }, + "V2ServiceUnavailable": { + "description": "Sim Chat is not configured or temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } } } } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index e837a5aba9b..6ba47422718 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4766,11 +4766,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "afterRowId": { "type": "string", "minLength": 1, @@ -4793,11 +4788,6 @@ "minLength": 1, "description": "The workspace that owns the table." }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "rows": { "type": "array", "minItems": 1, @@ -4836,11 +4826,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "limit": { "type": "integer", "minimum": 1, @@ -4921,11 +4906,6 @@ }, "data": { "$ref": "#/components/schemas/RowData" - }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." } } }, @@ -4942,11 +4922,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "conflictTarget": { "type": "string", "minLength": 1, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index ee12eb685b1..9c313c8f9e8 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1157,7 +1157,7 @@ "type": "integer", "minimum": 1, "maximum": 604800, - "description": "Server-side timeout for an async run, in seconds. Valid only when async is true." + "description": "Optional server-side timeout for an async run, in seconds. Requires async=true and cannot extend the account policy." }, "stream": { "type": "boolean", diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 6c52c2df02d..6366615da3c 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -229,3 +229,13 @@ export function useEntityList(workspaceId?: string) { - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index b270c7567cf..ff7902092a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), -})) +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetPermissions, +})) + import { POST } from '@/app/api/cli/auth/approve/route' const REQUEST = 'a'.repeat(43) @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) + mockGetPermissions.mockResolvedValue('admin') }) it('records the approval for the signed-in user', async () => { @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => { ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'copilot', + workspaceId: undefined, + workspaceBound: false, + }) + }) + + it('defaults to the copilot scope so pre-scope terminals keep working', async () => { + await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })) + expect(mockCreateApproval).toHaveBeenCalledWith( + 'user-1', + REQUEST, + CHALLENGE, + expect.objectContaining({ scope: 'copilot' }) + ) + }) + + it('records a workspace binding when the approver is a workspace admin', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it("records a non-admin's pick as a default without binding the key to it", async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('refuses to bind a key to a workspace the approver is not admin of', async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace the approver is not a member of', async () => { + mockGetPermissions.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(404) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses bindKeyToWorkspace with no workspaceId', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace binding on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() }) it('rejects an unauthenticated caller', async () => { @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => { await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) ) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything()) }) it('rejects a malformed challenge', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 8099914be91..3c361a9bf45 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI') * The approving user comes from the session and nothing else — a client-supplied * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. + * + * Workspace binding is authorized here rather than at poll time: the poll is + * unauthenticated by necessity, so it has no session to check a permission + * against. Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) - logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + + if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { + return NextResponse.json( + { error: 'workspaceId is only valid for the platform scope' }, + { status: 400 } + ) + } + + if (bindKeyToWorkspace && !workspaceId) { + return NextResponse.json( + { error: 'bindKeyToWorkspace requires a workspaceId' }, + { status: 400 } + ) + } + + if (workspaceId) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + // Reading the workspace at all requires membership. Without this, the + // terminal could be handed the id of a workspace the approver cannot see — + // harmless for the key, but it would silently become the profile default and + // every later command would 403 with no explanation. + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Minting a workspace key is an admin action wherever else it is offered; + // the terminal is not a lower bar. Rejected outright rather than downgraded + // to a personal key, so the CLI never quietly stores a different credential + // than the browser said it would. + if (bindKeyToWorkspace && permission !== 'admin') { + return NextResponse.json( + { error: 'Workspace admin permission is required to issue a workspace API key' }, + { status: 403 } + ) + } + } + + await createApproval(session.user.id, requestId, challenge, { + scope, + workspaceId, + workspaceBound: bindKeyToWorkspace, + }) + logger.info('Recorded CLI authorization approval', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + workspaceBound: bindKeyToWorkspace, + }) return NextResponse.json({ ok: true }) }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 89e7422a450..81709bd411b 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -9,12 +9,16 @@ const { mockCompleteApproval, mockReleaseMint, mockGenerateCopilotApiKey, + mockCreatePersonalApiKey, + mockCreateWorkspaceApiKey, mockEnforceIpRateLimit, } = vi.hoisted(() => ({ mockPollApproval: vi.fn(), mockCompleteApproval: vi.fn(), mockReleaseMint: vi.fn(), mockGenerateCopilotApiKey: vi.fn(), + mockCreatePersonalApiKey: vi.fn(), + mockCreateWorkspaceApiKey: vi.fn(), mockEnforceIpRateLimit: vi.fn(), })) @@ -29,6 +33,11 @@ vi.mock('@/lib/copilot/server/api-keys', () => ({ CopilotApiKeyError: class extends Error {}, })) +vi.mock('@/lib/api-key/orchestration', () => ({ + performCreatePersonalApiKey: mockCreatePersonalApiKey, + performCreateWorkspaceApiKey: mockCreateWorkspaceApiKey, +})) + vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit, })) @@ -42,11 +51,31 @@ function pollRequest(body: Record) { return createMockRequest('POST', body) } +/** What `pollApproval` returns for an approval recorded at the given scope. */ +function approved(overrides: Record = {}) { + return { + status: 'approved', + userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, + ...overrides, + } +} + describe('POST /api/cli/auth/poll', () => { beforeEach(() => { vi.clearAllMocks() mockEnforceIpRateLimit.mockResolvedValue(null) mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCreatePersonalApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-2', name: 'CLI', key: 'sim_personal', createdAt: new Date() }, + }) + mockCreateWorkspaceApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-3', name: 'CLI', key: 'sim_workspace', createdAt: new Date() }, + }) mockCompleteApproval.mockResolvedValue(undefined) mockReleaseMint.mockResolvedValue(undefined) }) @@ -60,20 +89,89 @@ describe('POST /api/cli/auth/poll', () => { }) it('mints, then consumes the approval, once approved', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) - expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + // Second precision, not day: a date-only name made the second login of the + // day fail after the user had already approved in the browser. + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith( + 'user-1', + expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/) + ) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) + it('mints a personal platform key when the approval carries no workspace', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: null, + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', source: 'cli' }) + ) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints a workspace-scoped key when the approval carries a workspace', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-3', apiKey: 'sim_workspace' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(mockCreateWorkspaceApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: 'ws-1', source: 'cli' }) + ) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + + it('returns the picked workspace with a personal key when the approval is unbound', async () => { + // A non-admin still picked a workspace in the browser; the terminal needs it + // as its default even though the key is not scoped to it. + mockPollApproval.mockResolvedValue(approved({ scope: 'platform', workspaceId: 'ws-1' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + }) + + it('scope comes from the approval, never from the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'copilot' })) + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, scope: 'platform' }) + ) + await expect(response.json()).resolves.toMatchObject({ scope: 'copilot' }) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(500) @@ -81,14 +179,30 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCompleteApproval).not.toHaveBeenCalled() }) + it('releases the reservation when a platform mint fails', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + mockCreatePersonalApiKey.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A personal API key named "CLI" already exists.', + }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(409) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockCompleteApproval.mockRejectedValue(new Error('redis blip')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // A cleanup failure must not release the mint lock — that would allow a re-mint. expect(mockReleaseMint).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c5a7610f9de..c3e6e8c00c3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + performCreatePersonalApiKey, + performCreateWorkspaceApiKey, +} from '@/lib/api-key/orchestration' +import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' @@ -23,9 +28,64 @@ const POLL_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -/** Keys are named for the day they were issued, matching what the CLI prints. */ +/** + * Names a minted key for the instant it was issued, e.g. `CLI (2026-07-30 + * 15:42:07Z)`. + * + * Second precision, not day: key names are unique per owner, so a date-only + * name made the second login of the day fail outright with "a key named … + * already exists" — after the user had already approved in the browser. UTC so + * the name is unambiguous in a shared workspace list and sorts chronologically. + */ function cliKeyName(): string { - return `CLI (${new Date().toISOString().slice(0, 10)})` + return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` +} + +/** + * Mints from the key space the approval recorded. + * + * A name collision is still surfaced rather than retried under a suffixed name: + * with second precision it means something genuinely unexpected, and silently + * accumulating near-identical rows would hide it. + */ +async function mintForGrant( + grant: ApprovalGrant +): Promise< + { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +> { + const name = cliKeyName() + + if (grant.scope === 'copilot') { + try { + const key = await generateCopilotApiKey(grant.userId, name) + return { ok: true, key } + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' } + } + } + + // `workspaceId` alone only names the terminal's default workspace; binding the + // key to it is a separate, admin-gated decision made at approval. + const result = + grant.workspaceBound && grant.workspaceId + ? await performCreateWorkspaceApiKey({ + workspaceId: grant.workspaceId, + userId: grant.userId, + name, + source: 'cli', + }) + : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) + + if (!result.success || !result.key) { + return { + ok: false, + status: result.errorCode === 'conflict' ? 409 : 500, + message: result.error ?? 'Failed to generate API key', + } + } + + return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } } /** @@ -49,17 +109,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ status: 'pending' }) } - let key: Awaited> - try { - key = await generateCopilotApiKey(result.userId, cliKeyName()) - } catch (error) { + const minted = await mintForGrant(result) + if (!minted.ok) { // Mint failed — release the reservation so a later poll can retry. await releaseMint(requestId) - const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: status ?? 500 } - ) + return NextResponse.json({ error: minted.message }, { status: minted.status }) } // Mint succeeded — the key exists. Consuming the approval is best-effort: a @@ -71,6 +125,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: result.userId, }) }) - logger.info('Minted CLI key on approved poll', { userId: result.userId }) - return NextResponse.json({ status: 'complete', key }) + logger.info('Minted CLI key on approved poll', { + userId: result.userId, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) + return NextResponse.json({ + status: 'complete', + key: minted.key, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) }) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d7d0ea2999d..df3dc0b9c40 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -234,6 +234,16 @@ describe('Knowledge Utils', () => { expect(result.hasAccess).toBe(false) expect('notFound' in result && result.notFound).toBe(true) }) + + it('treats a knowledge base outside the trusted workspace as not found', async () => { + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'kb1', userId: 'user1', workspaceId: 'workspace-2' }, + ]) + + const result = await checkKnowledgeBaseAccess('kb1', 'user1', 'workspace-1') + + expect(result).toEqual({ hasAccess: false, notFound: true }) + }) }) describe('checkDocumentAccess', () => { diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index e92dc49f419..11fac039123 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -163,7 +163,8 @@ export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { const kb = await db .select({ @@ -183,6 +184,10 @@ async function resolveKnowledgeBaseAccess( const kbData = kb[0] + if (workspaceId && kbData.workspaceId !== workspaceId) { + return { hasAccess: false, notFound: true } + } + if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) @@ -205,9 +210,10 @@ async function resolveKnowledgeBaseAccess( */ export async function checkKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false, workspaceId) } /** @@ -219,9 +225,10 @@ export async function checkKnowledgeBaseAccess( */ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true, workspaceId) } /** @@ -232,9 +239,15 @@ async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { - const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) + const kbAccess = await resolveKnowledgeBaseAccess( + knowledgeBaseId, + userId, + requireWrite, + workspaceId + ) if (!kbAccess.hasAccess) { return { @@ -262,9 +275,10 @@ async function resolveDocumentAccess( export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false, workspaceId) } /** @@ -274,9 +288,10 @@ export async function checkDocumentAccess( export async function checkDocumentWriteAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true, workspaceId) } /** diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index cd5f2eb83ca..b6776b51db6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,14 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,70 +71,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { name } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal'))) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A personal API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - userId, - workspaceId: null, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created personal API key: ${name}`, + const result = await performCreatePersonalApiKey({ + userId, + name, + actorName: session.user.name, + actorEmail: session.user.email, request, }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } captureServerEvent(userId, 'api_key_created', { key_name: name, scope: 'personal', }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) + return NextResponse.json({ key: result.key }) } catch (error) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) diff --git a/apps/sim/app/api/v2/chat/activity.test.ts b/apps/sim/app/api/v2/chat/activity.test.ts new file mode 100644 index 00000000000..feed95c1305 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.test.ts @@ -0,0 +1,380 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { MothershipStreamV1StreamScope } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { ChatActivityProjector } from '@/app/api/v2/chat/activity' + +vi.mock('@/lib/copilot/tools/client/read-block', () => ({ + getReadTargetBlock: vi.fn((path: string | undefined) => + path?.startsWith('components/') ? { name: 'Gmail' } : undefined + ), +})) + +const call = (over: Record = {}) => ({ + toolCallId: 'private-call-id', + toolName: 'read', + phase: 'call', + arguments: { secret: 'never-forward-me' }, + executor: 'go', + mode: 'sync', + ...over, +}) + +const result = (over: Record = {}) => + call({ + phase: 'result', + success: true, + output: { secret: 'never-forward-me' }, + arguments: undefined, + ...over, + }) + +const tool = (payload: Record, scope?: MothershipStreamV1StreamScope) => + ({ type: 'tool', payload, ...(scope ? { scope } : {}) }) as StreamEvent + +const span = ( + event: 'start' | 'end', + scope: MothershipStreamV1StreamScope, + over: Record = {} +) => + ({ + type: 'span', + scope, + payload: { kind: 'subagent', event, agent: scope.agentId, ...over }, + }) as StreamEvent + +const text = ( + channel: 'assistant' | 'thinking', + value: string, + scope?: MothershipStreamV1StreamScope +) => + ({ + type: 'text', + payload: { channel, text: value }, + ...(scope ? { scope } : {}), + }) as StreamEvent + +const researchScope: MothershipStreamV1StreamScope = { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch-id', + spanId: 'private-research-span', + parentSpanId: 'main', +} + +describe('ChatActivityProjector', () => { + it('correlates a visible root call and result without exposing their raw payload', () => { + const projector = new ChatActivityProjector() + + const [running] = projector.project(tool(call())) + const [complete] = projector.project(tool(result())) + + expect(running).toEqual({ + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }) + expect(complete).toEqual({ ...running, label: 'Read file', state: 'complete' }) + expect(JSON.stringify([running, complete])).not.toContain('private-call-id') + expect(JSON.stringify([running, complete])).not.toContain('never-forward-me') + }) + + it.each([ + ['workflows/forceful-arm/state.json', 'forceful-arm'], + ['components/blocks/gmail_v2.json', 'Gmail'], + ['components/integrations/gmail/send.json', 'Gmail'], + ])('uses the web read label for %s without forwarding arguments', (path, target) => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(tool(call({ arguments: { path, secret: 'never-forward-me' } }))), + ...projector.project(tool(result())), + ] + + expect(activities).toEqual([ + { kind: 'tool', id: 'tool-1', label: `Reading ${target}`, state: 'running' }, + { kind: 'tool', id: 'tool-1', label: `Read ${target}`, state: 'complete' }, + ]) + expect(JSON.stringify(activities)).not.toContain(path) + expect(JSON.stringify(activities)).not.toContain('never-forward-me') + }) + + it('maps failed and skipped terminal outcomes', () => { + const failed = new ChatActivityProjector() + failed.project(tool(call())) + expect(failed.project(tool(result({ success: false, error: 'private failure' })))).toEqual([ + expect.objectContaining({ label: 'Reading file', state: 'error' }), + ]) + + expect( + new ChatActivityProjector().project(tool(call({ status: 'skipped', success: false }))) + ).toEqual([expect.objectContaining({ label: 'Reading file', state: 'complete' })]) + + for (const status of ['cancelled', 'rejected']) { + const projector = new ChatActivityProjector() + projector.project(tool(call())) + expect(projector.project(tool(result({ status, success: true })))[0]).toMatchObject({ + state: 'error', + }) + } + }) + + it('waits for an authoritative call and holds an early result', () => { + const generating = new ChatActivityProjector() + expect(generating.project(tool(call({ partial: true, status: 'generating' })))).toEqual([]) + expect(generating.project(tool(call({ partial: false, status: 'executing' })))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }, + ]) + + const reordered = new ChatActivityProjector() + expect(reordered.project(tool(result()))).toEqual([]) + expect(reordered.project(tool(call()))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Read file', + state: 'complete', + }, + ]) + }) + + it('suppresses hidden, internal, and internal-result calls without id gaps', () => { + const projector = new ChatActivityProjector() + + for (const payload of [ + call({ toolCallId: 'hidden', ui: { hidden: true } }), + call({ toolCallId: 'internal', ui: { internal: true } }), + call({ toolCallId: 'legacy', toolName: 'load_skill' }), + call({ + toolCallId: 'tool-result-read', + arguments: { path: 'internal/tool-results/private' }, + }), + ]) { + expect(projector.project(tool(payload))).toEqual([]) + } + + expect(projector.project(tool(call({ toolCallId: 'visible' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('provisions root and nested subagent lanes from dispatch calls before span start', () => { + const root = new ChatActivityProjector() + expect( + root.project(tool(call({ toolCallId: 'workflow-dispatch', toolName: 'workflow' }))) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Workflow Agent', + state: 'running', + }, + ]) + const workflowScope = { + lane: 'subagent' as const, + agentId: 'workflow', + spanId: 'workflow-span', + parentSpanId: 'main', + parentToolCallId: 'workflow-dispatch', + } + expect(root.project(span('start', workflowScope))).toEqual([]) + + const nested = new ChatActivityProjector() + nested.project(span('start', researchScope)) + expect( + nested.project( + tool(call({ toolCallId: 'deploy-dispatch', toolName: 'deploy' }), researchScope) + ) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-2', + parentId: 'agent-1', + label: 'Deploy Agent', + state: 'running', + }, + ]) + expect( + nested.project( + span('start', { + lane: 'subagent', + agentId: 'deploy', + spanId: 'deploy-span', + parentSpanId: researchScope.spanId, + parentToolCallId: 'deploy-dispatch', + }) + ) + ).toEqual([]) + }) + + it('projects subagent lifecycle, scoped tools, and narration as an opaque tree', () => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(span('start', researchScope)), + ...projector.project(tool(call({ toolCallId: 'private-child-tool' }), researchScope)), + ...projector.project(text('assistant', 'I found the answer.', researchScope)), + ...projector.project(text('thinking', 'private chain of thought', researchScope)), + // Sim/client tool results are synthesized without their original scope. + ...projector.project(tool(result({ toolCallId: 'private-child-tool' }))), + ...projector.project(span('end', researchScope)), + ] + + expect(activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Reading file', + state: 'running', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'I found the answer.' }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + ]) + const serialized = JSON.stringify(activities) + for (const privateValue of [ + 'private-child-tool', + 'private-dispatch-id', + 'private-research-span', + 'never-forward-me', + 'private chain of thought', + ]) { + expect(serialized).not.toContain(privateValue) + } + }) + + it('nests subagents by opaque span parent ids and keeps parallel same-name runs distinct', () => { + const projector = new ChatActivityProjector() + const parent = { ...researchScope, spanId: 'parent', parentToolCallId: 'parent-call' } + const child = { + ...researchScope, + spanId: 'child', + parentSpanId: 'parent', + parentToolCallId: 'child-call', + } + const sibling = { + ...researchScope, + spanId: 'sibling', + parentToolCallId: 'sibling-call', + } + + expect(projector.project(span('start', parent))).toEqual([ + expect.objectContaining({ id: 'agent-1', label: 'Research Agent' }), + ]) + expect(projector.project(span('start', child))).toEqual([ + expect.objectContaining({ id: 'agent-2', parentId: 'agent-1' }), + ]) + expect(projector.project(span('start', sibling))).toEqual([ + expect.objectContaining({ id: 'agent-3', label: 'Research Agent' }), + ]) + }) + + it('reconciles a pre-start lane to the authoritative agent without changing its id', () => { + const projector = new ChatActivityProjector() + const provisional = { ...researchScope, agentId: 'superagent' } + + expect(projector.project(text('assistant', 'Starting.', provisional))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'Superagent' }), + { kind: 'narration', parentId: 'agent-1', delta: 'Starting.' }, + ]) + expect(projector.project(span('start', provisional, { agent: 'file' }))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'File Agent' }), + ]) + }) + + it('keeps pending span ends open and exposes terminal errors without their details', () => { + const projector = new ChatActivityProjector() + projector.project(span('start', researchScope)) + + expect(projector.project(span('end', researchScope, { data: { pending: true } }))).toEqual([]) + const terminal = projector.project( + span('end', researchScope, { data: { error: 'private backend failure' } }) + ) + expect(terminal).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'error', label: 'Research Agent' }), + ]) + expect(JSON.stringify(terminal)).not.toContain('private backend failure') + }) + + it('settles open tools and agents, using past tense only on success', () => { + const successful = new ChatActivityProjector() + successful.project(span('start', researchScope)) + successful.project(tool(call(), researchScope)) + expect(successful.finish('complete')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Read file', state: 'complete' }), + expect.objectContaining({ kind: 'subagent', state: 'complete' }), + ]) + expect(successful.finish('complete')).toEqual([]) + + const failed = new ChatActivityProjector() + failed.project(span('start', researchScope)) + failed.project(tool(call(), researchScope)) + expect(failed.finish('error')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Reading file', state: 'error' }), + expect.objectContaining({ kind: 'subagent', state: 'error' }), + ]) + }) + + it('absorbs a workspace_file dispatch into its matching file subagent', () => { + const projector = new ChatActivityProjector() + const workspaceCall = call({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' }) + const fileScope = { + lane: 'subagent' as const, + agentId: 'file', + spanId: 'file-span', + parentSpanId: 'main', + parentToolCallId: 'workspace-dispatch', + } + + expect(projector.project(tool(workspaceCall))).toEqual([]) + expect( + projector.project( + tool(result({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' })) + ) + ).toEqual([]) + expect(projector.project(span('start', fileScope))).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'File Agent', + state: 'running', + }, + ]) + expect(projector.project(tool(call({ toolCallId: 'visible-root' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('drops argument deltas, synthetic preview frames, and malformed events', () => { + const projector = new ChatActivityProjector() + + expect(projector.project(tool(call({ phase: 'args_delta' })))).toEqual([]) + expect(projector.project(tool(call({ phase: undefined })))).toEqual([]) + expect(projector.project(tool(call({ toolCallId: '' })))).toEqual([]) + expect(projector.project(tool(call({ toolName: undefined })))).toEqual([]) + }) +}) diff --git a/apps/sim/app/api/v2/chat/activity.ts b/apps/sim/app/api/v2/chat/activity.ts new file mode 100644 index 00000000000..fde09002790 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.ts @@ -0,0 +1,605 @@ +import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + type MothershipStreamV1StreamScope, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { getToolEntry } from '@/lib/copilot/tool-executor/router' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { getSubagentDisplayTitle } from '@/lib/copilot/tools/subagent-display' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' + +type ActivityState = 'running' | 'complete' | 'error' + +/** A display-safe node in the public v2 chat activity tree. */ +export interface V2ChatNodeActivity { + kind: 'subagent' | 'tool' + id: string + parentId?: string + label: string + state: ActivityState +} + +/** Display-safe assistant narration authored inside a subagent lane. */ +export interface V2ChatNarrationActivity { + kind: 'narration' + parentId: string + delta: string +} + +export type V2ChatActivity = V2ChatNodeActivity | V2ChatNarrationActivity + +interface ToolEventPayload { + toolCallId?: unknown + toolName?: unknown + arguments?: unknown + output?: unknown + partial?: unknown + phase?: unknown + status?: unknown + success?: unknown + ui?: { hidden?: unknown; internal?: unknown } | null +} + +interface ToolProjection { + id?: string + label?: string + parentId?: string + state?: ActivityState + status?: string + visibility: 'pending' | 'visible' | 'hidden' + pendingState?: ActivityState + pendingStatus?: string +} + +interface AgentProjection { + id: string + label: string + parentId?: string + state: ActivityState + emitted: boolean +} + +interface ProjectedToolState { + state: ActivityState + status: string +} + +interface DeferredWorkspaceFile { + call: ToolEventPayload + result?: ToolEventPayload +} + +const ERROR_STATUSES = new Set([ + MothershipStreamV1ToolStatus.error, + MothershipStreamV1ToolStatus.cancelled, + MothershipStreamV1ToolStatus.rejected, +]) +const MAIN_SPAN = 'main' +const WORKSPACE_FILE_TOOL = 'workspace_file' +const FILE_SUBAGENT = 'file' + +/** + * Request-local projection of the private Mothership stream onto the public + * activity tree. Raw span/tool ids, arguments, results, errors, and thinking + * never cross this boundary. + */ +export class ChatActivityProjector { + private readonly calls = new Map() + private readonly agentsByKey = new Map() + private readonly agents: AgentProjection[] = [] + private deferredWorkspaceFile?: DeferredWorkspaceFile + private nextToolId = 1 + private nextAgentId = 1 + + project(event: StreamEvent): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.captureDeferredWorkspaceFileResult(event)) return activities + + const absorbsWorkspaceFile = this.absorbsDeferredWorkspaceFile(event) + if (this.deferredWorkspaceFile && !absorbsWorkspaceFile && this.breaksDeferral(event)) { + activities.push(...this.flushDeferredWorkspaceFile()) + } + if (absorbsWorkspaceFile) this.hideDeferredWorkspaceFile() + + if (this.deferWorkspaceFileCall(event)) return activities + + switch (event.type) { + case MothershipStreamV1EventType.span: + activities.push(...this.projectSpan(event.payload, event.scope)) + break + case MothershipStreamV1EventType.text: + activities.push(...this.projectText(event.payload, event.scope)) + break + case MothershipStreamV1EventType.tool: + activities.push(...this.projectTool(event.payload, event.scope)) + break + } + + return activities + } + + /** Settle every public row before the route sends its terminal envelope. */ + finish(outcome: 'complete' | 'error'): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.deferredWorkspaceFile) { + const deferred = this.flushDeferredWorkspaceFile() + const last = deferred.at(-1) + // A deferred call was never visible. If it already completed, expose only + // its terminal snapshot; otherwise the normal settlement below closes it. + if (last?.kind === 'tool' && last.state !== 'running') activities.push(last) + } + + for (const projection of this.calls.values()) { + if ( + projection.visibility !== 'visible' || + !projection.id || + !projection.label || + projection.state !== 'running' + ) { + continue + } + activities.push( + this.toolActivity(projection, { + state: outcome === 'complete' ? 'complete' : 'error', + status: + outcome === 'complete' + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error, + }) + ) + } + + // Children close before their parents, matching the visible activity tree. + for (const agent of [...this.agents].reverse()) { + if (!agent.emitted || agent.state !== 'running') continue + agent.state = outcome + activities.push(this.agentActivity(agent)) + } + + return activities + } + + private projectSpan(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const span = record(payload) + if (span?.kind !== MothershipStreamV1SpanPayloadKind.subagent) return [] + if ( + span.event !== MothershipStreamV1SpanLifecycleEvent.start && + span.event !== MothershipStreamV1SpanLifecycleEvent.end + ) { + return [] + } + + const data = record(span.data) + const triggerToolCallId = + stringValue(scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + const authoritativeAgent = stringValue(span.agent) + const resolved = this.ensureAgent(scope, authoritativeAgent, triggerToolCallId, false) + if (!resolved) return [] + const { agent, changed } = resolved + + if (span.event === MothershipStreamV1SpanLifecycleEvent.start) { + const stateChanged = agent.state !== 'running' + agent.state = 'running' + if (!agent.emitted || changed || stateChanged) { + agent.emitted = true + return [this.agentActivity(agent)] + } + return [] + } + + // A checkpoint pause is resumable, not a completed subagent run. + if (data?.pending === true) return [] + agent.state = stringValue(data?.error) ? 'error' : 'complete' + agent.emitted = true + return [this.agentActivity(agent)] + } + + private projectText(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const text = record(payload) + if ( + !scope || + text?.channel !== MothershipStreamV1TextChannel.assistant || + typeof text.text !== 'string' || + !text.text + ) { + return [] + } + + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) return [] + return [ + ...resolved.activities, + { kind: 'narration', parentId: resolved.agent.id, delta: text.text }, + ] + } + + private projectTool(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + if (!payload || typeof payload !== 'object') return [] + const tool = payload as ToolEventPayload + if (tool.phase === MothershipStreamV1ToolPhase.args_delta) return [] + if ( + tool.phase !== MothershipStreamV1ToolPhase.call && + tool.phase !== MothershipStreamV1ToolPhase.result + ) { + return [] + } + + const callId = stringValue(tool.toolCallId) + const toolName = stringValue(tool.toolName) + if (!callId || !toolName) return [] + + const catalog = getToolEntry(toolName) + if (catalog?.route === 'subagent') { + this.calls.set(callId, { visibility: 'hidden' }) + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating + ) { + return [] + } + return this.projectSubagentDispatch(callId, catalog.subagentId ?? toolName, scope) + } + + const existing = this.calls.get(callId) + if (existing?.visibility === 'hidden') return [] + + if (this.isHidden(toolName, tool)) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + + const activities: V2ChatActivity[] = [] + let parentId = existing?.parentId + if (scope) { + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + activities.push(...resolved.activities) + parentId = resolved.agent.id + } + + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const projectedState = toolState(tool) + if (!existing || existing.visibility !== 'visible' || !existing.label) { + this.calls.set(callId, { + label: existing?.label, + parentId, + visibility: 'pending', + pendingState: projectedState.state, + pendingStatus: projectedState.status, + }) + return activities + } + existing.parentId ??= parentId + existing.pendingState = projectedState.state + existing.pendingStatus = projectedState.status + activities.push(this.toolActivity(existing, projectedState)) + return activities + } + + const projection = existing ?? { visibility: 'pending' as const } + const toolArguments = record(tool.arguments) + const resolvedReadTargetName = + toolName === 'read' ? getReadTargetBlock(stringValue(toolArguments?.path))?.name : undefined + projection.label = getToolDisplayTitle(toolName, toolArguments, resolvedReadTargetName) + projection.parentId ??= parentId + + // Generating calls can later resolve to a hidden/internal tool. Wait for + // the authoritative call so the terminal never paints an orphan row. + if (tool.partial === true || tool.status === MothershipStreamV1ToolStatus.generating) { + this.calls.set(callId, projection) + return activities + } + + projection.visibility = 'visible' + projection.id ??= this.publicToolId() + this.calls.set(callId, projection) + const projectedState = toolState(tool) + activities.push( + this.toolActivity(projection, { + state: projection.pendingState ?? projectedState.state, + status: projection.pendingStatus ?? projectedState.status, + }) + ) + return activities + } + + private ensureAgent( + scope: MothershipStreamV1StreamScope | undefined, + authoritativeAgent?: string, + triggerToolCallId?: string, + emit = true + ): + | { + agent: AgentProjection + activities: V2ChatActivity[] + changed: boolean + } + | undefined { + if (!scope || scope.lane !== 'subagent') return undefined + const spanId = stringValue(scope.spanId) + const triggerId = triggerToolCallId ?? stringValue(scope.parentToolCallId) + const spanKey = spanId ? `span:${spanId}` : undefined + const callKey = triggerId ? `call:${triggerId}` : undefined + if (!spanKey && !callKey) return undefined + + let agent = + (spanKey ? this.agentsByKey.get(spanKey) : undefined) ?? + (callKey ? this.agentsByKey.get(callKey) : undefined) + if (!agent) { + agent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(authoritativeAgent ?? scope.agentId ?? ''), + parentId: this.parentAgentId(scope, spanId), + state: 'running', + emitted: false, + } + this.agents.push(agent) + } + if (spanKey) this.agentsByKey.set(spanKey, agent) + if (callKey) this.agentsByKey.set(callKey, agent) + + let changed = false + if (authoritativeAgent) { + const label = getSubagentDisplayTitle(authoritativeAgent) + if (label !== agent.label) { + agent.label = label + changed = true + } + } + const parentId = this.parentAgentId(scope, spanId) + if (parentId && parentId !== agent.parentId) { + agent.parentId = parentId + changed = true + } + + const activities: V2ChatActivity[] = [] + if (emit && (!agent.emitted || changed)) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return { agent, activities, changed } + } + + private projectSubagentDispatch( + callId: string, + agentId: string, + scope?: MothershipStreamV1StreamScope + ): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + let parentId: string | undefined + if (scope) { + const parent = this.ensureAgent(scope, undefined, undefined, true) + if (parent) { + activities.push(...parent.activities) + parentId = parent.agent.id + } + } + + const key = `call:${callId}` + let agent = this.agentsByKey.get(key) + const label = getSubagentDisplayTitle(agentId) + if (!agent) { + agent = { + id: this.publicAgentId(), + label, + ...(parentId ? { parentId } : {}), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, agent) + this.agents.push(agent) + } + const changed = agent.label !== label || (!!parentId && agent.parentId !== parentId) + agent.label = label + agent.parentId ??= parentId + agent.state = 'running' + if (!agent.emitted || changed) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return activities + } + + private parentAgentId( + scope: MothershipStreamV1StreamScope, + ownSpanId?: string + ): string | undefined { + const parentSpanId = stringValue(scope.parentSpanId) + if (!parentSpanId || parentSpanId === MAIN_SPAN || parentSpanId === ownSpanId) return undefined + const key = `span:${parentSpanId}` + let parent = this.agentsByKey.get(key) + if (!parent) { + parent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(''), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, parent) + this.agents.push(parent) + } + return parent.id + } + + private isHidden(toolName: string, tool: ToolEventPayload): boolean { + const catalog = getToolEntry(toolName) + return ( + tool.ui?.hidden === true || + tool.ui?.internal === true || + catalog?.hidden === true || + catalog?.internal === true || + isToolHiddenInUi(toolName) || + (toolName === 'read' && + stringValue(record(tool.arguments)?.path)?.startsWith('internal/tool-results/') === true) + ) + } + + private deferWorkspaceFileCall(event: StreamEvent): boolean { + if (event.type !== MothershipStreamV1EventType.tool || event.scope) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating || + this.isHidden(WORKSPACE_FILE_TOOL, tool) + ) { + return false + } + this.deferredWorkspaceFile = { call: tool } + return true + } + + private captureDeferredWorkspaceFileResult(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if (!deferred || event.type !== MothershipStreamV1EventType.tool) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.result || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.toolCallId !== deferred.call.toolCallId + ) { + return false + } + deferred.result = tool + return true + } + + private absorbsDeferredWorkspaceFile(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if ( + !deferred || + event.type !== MothershipStreamV1EventType.span || + event.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent || + event.payload.event !== MothershipStreamV1SpanLifecycleEvent.start + ) { + return false + } + const data = record(event.payload.data) + const agent = stringValue(event.payload.agent) ?? stringValue(event.scope?.agentId) + const triggerId = + stringValue(event.scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + return agent === FILE_SUBAGENT && triggerId === deferred.call.toolCallId + } + + private hideDeferredWorkspaceFile(): void { + const deferred = this.deferredWorkspaceFile + if (!deferred) return + const callId = stringValue(deferred.call.toolCallId) + if (callId) this.calls.set(callId, { visibility: 'hidden' }) + this.deferredWorkspaceFile = undefined + } + + private flushDeferredWorkspaceFile(): V2ChatActivity[] { + const deferred = this.deferredWorkspaceFile + if (!deferred) return [] + this.deferredWorkspaceFile = undefined + return [ + ...this.projectTool(deferred.call), + ...(deferred.result ? this.projectTool(deferred.result) : []), + ] + } + + private breaksDeferral(event: StreamEvent): boolean { + if (event.type === MothershipStreamV1EventType.tool) { + const tool = event.payload as ToolEventPayload + return tool.phase !== MothershipStreamV1ToolPhase.args_delta + } + if (event.type === MothershipStreamV1EventType.text) { + return ( + event.payload.channel === MothershipStreamV1TextChannel.assistant && !!event.payload.text + ) + } + if (event.type === MothershipStreamV1EventType.span) { + return event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent + } + return ( + event.type === MothershipStreamV1EventType.error || + event.type === MothershipStreamV1EventType.complete + ) + } + + private publicToolId(): string { + return `tool-${this.nextToolId++}` + } + + private publicAgentId(): string { + return `agent-${this.nextAgentId++}` + } + + private agentActivity(agent: AgentProjection): V2ChatNodeActivity { + return { + kind: 'subagent', + id: agent.id, + ...(agent.parentId ? { parentId: agent.parentId } : {}), + label: agent.label, + state: agent.state, + } + } + + private toolActivity( + projection: ToolProjection, + projectedState: ProjectedToolState + ): V2ChatNodeActivity { + projection.state = projectedState.state + projection.status = projectedState.status + return { + kind: 'tool', + id: projection.id!, + ...(projection.parentId ? { parentId: projection.parentId } : {}), + label: getToolStatusDisplayTitle(projection.label!, projectedState.status), + state: projectedState.state, + } + } +} + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function toolState(tool: ToolEventPayload): ProjectedToolState { + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const outcome = resolveStreamToolOutcome({ + output: tool.output, + ...(typeof tool.status === 'string' ? { status: tool.status } : {}), + ...(typeof tool.success === 'boolean' ? { success: tool.success } : {}), + }) + return { + state: + outcome === MothershipStreamV1ToolOutcome.success || + outcome === MothershipStreamV1ToolOutcome.skipped + ? 'complete' + : 'error', + status: outcome, + } + } + const status = typeof tool.status === 'string' ? tool.status : 'running' + if (tool.status === MothershipStreamV1ToolStatus.success) return { state: 'complete', status } + if (tool.status === MothershipStreamV1ToolStatus.skipped) return { state: 'complete', status } + if (ERROR_STATUSES.has(status)) return { state: 'error', status } + return { state: 'running', status } +} diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts new file mode 100644 index 00000000000..09fd941b55a --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -0,0 +1,1549 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAcquirePendingChatStream, + mockCheckAttributedUsageLimits, + mockCheckRateLimit, + mockClearFilePreviewSessions, + mockCleanupAbortMarker, + mockCreateRunSegment, + mockEnv, + mockEnvFlags, + mockFinalizeStream, + mockFireTitleGeneration, + mockGenerateId, + mockGetAccessibleCopilotChatContinuationMetadata, + mockIssueV2ChatContinuationToken, + mockPersistCopilotUserMessage, + mockPrepareV2ChatAttachments, + mockPublishStatusChanged, + mockPublisherClose, + mockPublisherFlush, + mockPublisherPublish, + mockRegisterActiveStream, + mockReleasePendingChatStream, + mockResetBuffer, + mockResolveOrCreateChat, + mockRequestExplicitStreamAbort, + mockResolveBillingAttribution, + mockResolveSystemBillingAttribution, + mockResolveWorkspaceAccess, + mockRunWorkspaceChat, + mockScheduleBufferCleanup, + mockScheduleFilePreviewSessionCleanup, + mockStartAbortPoller, + mockStreamWriter, + mockTurnOnComplete, + mockTurnOnError, + mockUnregisterActiveStream, + mockVerifyV2ChatContinuationToken, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockAcquirePendingChatStream: vi.fn(), + mockCheckAttributedUsageLimits: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockClearFilePreviewSessions: vi.fn(), + mockCleanupAbortMarker: vi.fn(), + mockCreateRunSegment: vi.fn(), + mockEnv: { COPILOT_API_KEY: 'deployment-mothership-key' as string | undefined }, + mockEnvFlags: { isAuthDisabled: false }, + mockFinalizeStream: vi.fn(), + mockFireTitleGeneration: vi.fn(), + mockGenerateId: vi.fn(), + mockGetAccessibleCopilotChatContinuationMetadata: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPersistCopilotUserMessage: vi.fn(), + mockPrepareV2ChatAttachments: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockPublisherClose: vi.fn(), + mockPublisherFlush: vi.fn(), + mockPublisherPublish: vi.fn(), + mockRegisterActiveStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + mockResetBuffer: vi.fn(), + mockResolveOrCreateChat: vi.fn(), + mockRequestExplicitStreamAbort: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockResolveSystemBillingAttribution: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockRunWorkspaceChat: vi.fn(), + mockScheduleBufferCleanup: vi.fn(), + mockScheduleFilePreviewSessionCleanup: vi.fn(), + mockStartAbortPoller: vi.fn(), + mockStreamWriter: vi.fn(), + mockTurnOnComplete: vi.fn(), + mockTurnOnError: vi.fn(), + mockUnregisterActiveStream: vi.fn(), + mockVerifyV2ChatContinuationToken: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedUsageLimits: mockCheckAttributedUsageLimits, + resolveBillingAttribution: mockResolveBillingAttribution, + resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment: mockCreateRunSegment, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatContinuationMetadata: mockGetAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat: mockResolveOrCreateChat, +})) + +vi.mock('@/lib/copilot/chat/turn-persistence', () => ({ + buildCopilotTurnOnComplete: () => mockTurnOnComplete, + buildCopilotTurnOnError: () => mockTurnOnError, + persistCopilotUserMessage: mockPersistCopilotUserMessage, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ + runWorkspaceChat: mockRunWorkspaceChat, + publicChatUsageLimitMessage: (content: string) => { + const match = /^(.+)<\/usage_upgrade>$/.exec(content) + if (!match) return null + return (JSON.parse(match[1]) as { message: string }).message + }, + toPublicChatResult: ( + result: { content: string; usage?: { prompt: number; completion: number } }, + continuationToken: string + ) => ({ + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + }), +})) + +vi.mock('@/lib/copilot/headless/attachments', () => ({ + prepareV2ChatAttachments: mockPrepareV2ChatAttachments, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, + verifyV2ChatContinuationToken: mockVerifyV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +vi.mock('@/lib/copilot/request/lifecycle/finalize', () => ({ + finalizeStream: mockFinalizeStream, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + fireTitleGeneration: mockFireTitleGeneration, +})) + +vi.mock('@/lib/copilot/request/session', () => ({ + AbortReason: { UserStop: 'user_stop:abortActiveStream' }, + StreamWriter: mockStreamWriter, + acquirePendingChatStream: mockAcquirePendingChatStream, + clearFilePreviewSessions: mockClearFilePreviewSessions, + cleanupAbortMarker: mockCleanupAbortMarker, + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), + encodeSSEEnvelope: (value: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`), + registerActiveStream: mockRegisterActiveStream, + releasePendingChatStream: mockReleasePendingChatStream, + resetBuffer: mockResetBuffer, + scheduleBufferCleanup: mockScheduleBufferCleanup, + scheduleFilePreviewSessionCleanup: mockScheduleFilePreviewSessionCleanup, + SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, + startAbortPoller: mockStartAbortPoller, + unregisterActiveStream: mockUnregisterActiveStream, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' +import { POST } from '@/app/api/v2/chat/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'key-owner-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-05T12:00:00.000Z'), +} + +const personalAttribution = { + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: null, + billingEntity: { type: 'user' as const, id: 'payer-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const systemAttribution = { + ...personalAttribution, + actorUserId: 'workspace-billed-account', +} + +function callChat(body: Record, headers: Record = {}) { + return POST( + createMockRequest( + 'POST', + body, + { 'Content-Type': 'application/json', 'x-api-key': 'caller-platform-key', ...headers }, + 'http://localhost:3000/api/v2/chat' + ) + ) +} + +function parseSse(stream: string): Record[] { + return stream + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice('data: '.length)) as Record) +} + +describe('POST /api/v2/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.COPILOT_API_KEY = 'deployment-mothership-key' + mockEnvFlags.isAuthDisabled = false + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-1') + .mockReturnValueOnce('execution-1') + .mockReturnValueOnce('run-1') + .mockReturnValue('generated-extra') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', type: 'mothership', title: null }, + conversationHistory: [], + isNew: true, + }) + mockStreamWriter.mockImplementation(function MockStreamWriter() { + return { + close: mockPublisherClose, + flush: mockPublisherFlush, + publish: mockPublisherPublish, + sawComplete: false, + } + }) + mockIssueV2ChatContinuationToken.mockReturnValue('continuation-new') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValue(null) + mockVerifyV2ChatContinuationToken.mockReturnValue({ valid: false }) + mockPrepareV2ChatAttachments.mockReturnValue({ success: true, attachments: [] }) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockResolveBillingAttribution.mockResolvedValue(personalAttribution) + mockResolveSystemBillingAttribution.mockResolvedValue(systemAttribution) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mockAcquirePendingChatStream.mockResolvedValue(true) + mockClearFilePreviewSessions.mockResolvedValue(undefined) + mockCleanupAbortMarker.mockResolvedValue(undefined) + mockCreateRunSegment.mockResolvedValue({ id: 'run-1' }) + mockFinalizeStream.mockResolvedValue(undefined) + mockPersistCopilotUserMessage.mockResolvedValue(undefined) + mockPublisherClose.mockResolvedValue(undefined) + mockPublisherFlush.mockResolvedValue(undefined) + mockReleasePendingChatStream.mockResolvedValue(undefined) + mockResetBuffer.mockResolvedValue(undefined) + mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockScheduleBufferCleanup.mockResolvedValue(undefined) + mockScheduleFilePreviewSessionCleanup.mockResolvedValue(undefined) + mockStartAbortPoller.mockReturnValue(0) + mockRunWorkspaceChat.mockImplementation(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + return { + success: true, + content: 'Hello from Sim', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 8, completion: 3 }, + } + }) + }) + + it('streams a personal-key chat and bills its authenticated actor', async () => { + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(stream).toContain('"type":"session"') + expect(stream).toContain('"continuationToken":"continuation-new"') + expect(stream).toContain('"chatId":"chat-1"') + expect(stream).toContain('"delta":"Hello from Sim"') + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('data: [DONE]') + + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billingAttribution: personalAttribution, + readOnly: false, + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentialType: 'personal', + readOnly: false, + persistence: 'sim', + }) + ) + expect(mockRunWorkspaceChat.mock.calls[0][0]).not.toHaveProperty('apiKey') + expect(mockAcquirePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockRegisterActiveStream).toHaveBeenCalledWith( + 'message-1', + expect.any(AbortController), + expect.any(AbortController) + ) + expect(mockStartAbortPoller).toHaveBeenCalledWith('message-1', expect.any(AbortController), { + requestId: 'request-1', + chatId: 'chat-1', + userStopController: expect.any(AbortController), + }) + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith({ + userId: 'key-owner-1', + workspaceId: 'workspace-1', + model: 'claude-opus-4-8', + type: 'mothership', + }) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'created', + }) + expect(mockCreateRunSegment).toHaveBeenCalledWith({ + id: 'run-1', + executionId: 'execution-1', + chatId: 'chat-1', + userId: 'key-owner-1', + workspaceId: 'workspace-1', + streamId: 'message-1', + model: null, + requestContext: { requestId: 'request-1', source: 'v2_chat' }, + }) + expect(mockResetBuffer).toHaveBeenCalledWith('message-1') + expect(mockClearFilePreviewSessions).toHaveBeenCalledWith('message-1') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith({ + chatId: 'chat-1', + userMessageId: 'message-1', + message: 'What is here?', + contexts: undefined, + workspaceId: 'workspace-1', + notifyWorkspaceStatus: true, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'chat', chatId: 'chat-1' }, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Hello from Sim' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + expect(mockFireTitleGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + isNewChat: true, + message: 'What is here?', + workspaceId: 'workspace-1', + }) + ) + expect(mockPublisherClose).toHaveBeenCalledTimes(1) + expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') + expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') + }) + + it('passes validated resource and slash contexts to workspace chat', async () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use @Release and /review with /Docs', + contexts, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ contexts })) + }) + + it('rejects malformed or unsupported public context variants', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Private folder' }], + }) + + expect(response.status).toBe(400) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails with a retryable conflict before exposing a session when the chat lease is busy', async () => { + mockAcquirePendingChatStream.mockResolvedValueOnce(false) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A response is already in progress for this chat', + }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRegisterActiveStream).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + }) + + it('does not issue a session token or start Mothership before the chat lease is acquired', async () => { + let acquire!: (value: boolean) => void + mockAcquirePendingChatStream.mockReturnValueOnce( + new Promise((resolve) => { + acquire = resolve + }) + ) + + const pendingResponse = callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + + acquire(true) + const response = await pendingResponse + const stream = await response.text() + expect(stream).toContain('"type":"session"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + }) + + it('does not expose the continuation token until Go accepts the initial stream', async () => { + let accept!: () => void + let settle!: () => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publisher.publish({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + } + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + accept = () => input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(firstReadSettled).toBe(false) + + accept() + const first = await firstRead + const acceptedSession = new TextDecoder().decode(first.value) + expect(acceptedSession).toContain('"type":"session"') + expect(acceptedSession).toContain('"continuationToken":"continuation-new"') + expect(acceptedSession).toContain('"title":"Release investigation"') + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) + + it('projects a title generated after session acceptance onto the public stream', async () => { + let publishTitle!: (event: unknown) => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publishTitle = publisher.publish + } + ) + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + publishTitle({ + type: 'session', + payload: { kind: 'title', title: 'Deployment failure' }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What failed?' }) + const events = parseSse(await response.text()) + + expect(events).toContainEqual({ + type: 'session', + chatId: 'chat-1', + title: 'Deployment failure', + }) + }) + + it('does not hold the Go leg on run-segment creation but waits before finalizing it', async () => { + let resolveRunSegment!: () => void + let resolveChat!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + input.onInitialStreamAccepted?.() + resolveChat = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + + resolveChat() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockFinalizeStream).not.toHaveBeenCalled() + + resolveRunSegment() + expect(await response.text()).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('keeps a synced turn working when run-segment creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'workspace setup failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const stream = await response.text() + + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + }) + + it('enables the subtractive query policy only when explicitly requested', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Only inspect this workspace', + readOnly: true, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'personal', readOnly: true }) + ) + }) + + it('continues a legacy Go-only chat without exposing or partially persisting it', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGenerateId.mockReset().mockReturnValue('message-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockVerifyV2ChatContinuationToken).toHaveBeenCalledWith('continuation-old', { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'private-chat-id', + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'private-chat-id', + messageId: 'message-followup', + }) + ) + expect(stream).toContain('"continuationToken":"continuation-refreshed"') + expect(stream).not.toContain('private-chat-id') + expect(mockGetAccessibleCopilotChatContinuationMetadata).toHaveBeenCalledWith( + 'private-chat-id', + 'key-owner-1' + ) + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('continues an existing persisted personal chat with UI replay enabled', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'shared-chat-1', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce({ + id: 'shared-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Existing chat', + hasMessages: true, + mcpServerIds: ['mcp-history'], + }) + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-followup') + .mockReturnValueOnce('execution-followup') + .mockReturnValueOnce('run-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-old', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"chatId":"shared-chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'shared-chat-1', + userMessageId: 'message-followup', + message: 'Continue', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServerIds: ['mcp-history'], + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'run-followup', + executionId: 'execution-followup', + chatId: 'shared-chat-1', + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'shared-chat-1', persistence: 'sim' }) + ) + }) + + it.each([ + ['missing or deleted', null], + [ + 'the wrong type', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + type: 'copilot', + title: 'Workflow chat', + hasMessages: true, + }, + ], + [ + 'from another workspace', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-2', + type: 'mothership', + title: 'Other workspace', + hasMessages: true, + }, + ], + ])('rejects an explicitly Sim-persisted continuation when its row is %s', async (_case, chat) => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'synced-chat-1', + persistence: 'sim', + }) + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce(chat) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-sim', + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails closed before billing or Mothership for an invalid continuation token', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: false }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'steal history', + continuationToken: 'tampered-or-cross-owner-token', + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid or expired continuation token' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('validates inline attachments and forwards only the server-mapped Mothership shape', async () => { + const publicAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + } + const mothershipAttachment = { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + } + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [mothershipAttachment], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [publicAttachment], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockPrepareV2ChatAttachments).toHaveBeenCalledWith([publicAttachment]) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ fileAttachments: [mothershipAttachment] }) + ) + }) + + it('normalizes an attachment-only turn to a neutral upstream prompt', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [ + { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + }, + ], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'notes.txt', mediaType: 'text/plain', data: 'aGk=' }], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Please inspect the attached file(s).' }) + ) + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please inspect the attached file(s).' }) + ) + }) + + it('returns a typed HTTP error before billing when attachment validation fails', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "clip.mp4" has unsupported media type video/mp4', + }, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Watch this', + attachments: [{ name: 'clip.mp4', mediaType: 'video/mp4', data: 'AAAA' }], + }) + + expect(response.status).toBe(415) + expect((await response.json()).error.code).toBe('UNSUPPORTED_MEDIA_TYPE') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the v2 payload-too-large envelope for an oversized raw body', async () => { + const response = await callChat( + { workspaceId: 'workspace-1', prompt: 'hello' }, + { 'Content-Length': String(MAX_V2_CHAT_BODY_BYTES + 1) } + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('forwards Mothership text events as deltas without prefix guessing', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'a' }, + }) + // This delta starts with all prior output. Treating events as possibly + // cumulative would incorrectly emit only "bc" here. + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'abc' }, + }) + return { + success: true, + content: 'aabc', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"delta":"a"') + expect(stream).toContain('"delta":"abc"') + expect(stream).not.toContain('"delta":"bc"') + }) + + it('projects scoped assistant narration without merging it into the public answer', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { channel: 'assistant', text: 'Scoped progress.' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'public final delta' }, + }) + return { + success: true, + content: 'public final delta', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + const events = parseSse(stream) + const activities = events.filter((event) => event.type === 'activity') + const answerText = events.filter((event) => event.type === 'text') + + expect(answerText).toEqual([{ type: 'text', delta: 'public final delta' }]) + expect(activities).toEqual([ + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + }, + { + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'Scoped progress.' }, + }, + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + }, + ]) + expect(stream).not.toContain('private-dispatch') + expect(stream).not.toContain('private-span') + }) + + it('projects a display-safe nested activity tree without private stream data', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'thinking', text: 'Inspecting the workspace' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'private-tool-id', + toolName: 'read', + arguments: { secret: 'never-forward-me' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + arguments: { secret: 'hidden-call-secret' }, + executor: 'sim', + mode: 'async', + ui: { hidden: true }, + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + output: { secret: 'hidden-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'call', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + arguments: { secret: 'scoped-secret' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'result', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + output: { secret: 'scoped-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { channel: 'thinking', text: 'private subagent reasoning' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'private-tool-id', + toolName: 'read', + output: { secret: 'never-forward-me' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('Done') + expect(stream).toContain('"type":"activity"') + expect(stream).toContain('"label":"Reading file"') + expect(stream).toContain('"label":"Read file"') + expect(stream).toContain('"label":"Research Agent"') + expect(stream).toContain('"label":"Private Scoped Tool"') + expect(stream).toContain('"parentId":"agent-1"') + expect(stream).toContain('"state":"running"') + expect(stream).toContain('"state":"complete"') + expect(stream.match(/"type":"activity"/g)).toHaveLength(6) + expect(stream).not.toContain('Inspecting the workspace') + expect(stream).not.toContain('private-tool-id') + expect(stream).not.toContain('private_hidden_tool') + expect(stream).not.toContain('private_scoped_tool') + expect(stream).not.toContain('private-research-span') + expect(stream).not.toContain('never-forward-me') + expect(stream).not.toContain('scoped-secret') + expect(stream).not.toContain('scoped-result-secret') + expect(stream).not.toContain('private subagent reasoning') + }) + + it('authorizes a workspace key as its creator but executes and bills as the system actor', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-owner-1', keyType: 'workspace' }), + 'key-owner-1', + 'workspace-1', + 'read' + ) + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'workspace', readOnly: false }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + sharedWorkspaceCredential: true, + }) + ) + expect(stream).not.toContain('"chatId":"chat-1"') + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + await response.text() + + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + }) + ) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'workspace-billed-account', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('supports the auth-disabled self-host principal while keeping upstream auth server-owned', async () => { + const anonymousAttribution = { + ...personalAttribution, + actorUserId: 'anonymous', + } + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + userId: 'anonymous', + keyType: 'personal', + }) + mockEnvFlags.isAuthDisabled = true + mockResolveBillingAttribution.mockResolvedValue(anonymousAttribution) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'anonymous', keyType: undefined }), + 'anonymous', + 'workspace-1', + 'read' + ) + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'anonymous', + workspaceId: 'workspace-1', + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'anonymous', + actorUserId: 'anonymous', + billingAttribution: anonymousAttribution, + }) + ) + expect(stream).toContain('"chatId":"chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1', message: 'What is here?' }) + ) + }) + + it('returns 402 before opening a stream or calling Mothership when usage is exhausted', async () => { + mockCheckAttributedUsageLimits.mockResolvedValue({ + isExceeded: true, + message: 'Organization usage limit exceeded', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Organization usage limit exceeded' }, + }) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('surfaces a raced or self-hosted upstream 402 as a structured stream error', async () => { + const upgrade = + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: upgrade }, + }) + return { + success: true, + content: upgrade, + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"code":"USAGE_LIMIT_EXCEEDED"') + expect(stream).toContain('Ask an org admin.') + expect(stream).not.toContain('') + expect(stream).not.toContain('"type":"complete"') + }) + + it('rejects a cross-workspace key before resolving a payer', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) + + const response = await callChat({ workspaceId: 'workspace-2', prompt: 'hello' }) + + expect(response.status).toBe(403) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns a clear 503 when the deployment has no Mothership key', async () => { + mockEnv.COPILOT_API_KEY = undefined + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Sim Chat is not configured on this deployment', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + }) + + it('does not leak an upstream failure body and explicitly stops detached generation', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream secret response body', + errors: ['provider internal detail'], + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(stream).toContain('"message":"Chat request failed"') + expect(stream).not.toContain('upstream secret response body') + expect(stream).not.toContain('provider internal detail') + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('rejects caller-controlled identity, model, and provider fields', async () => { + for (const forbidden of [ + { userId: 'forged-user' }, + { model: 'caller-model' }, + { provider: 'caller-provider' }, + { chatId: 'raw-private-chat-id' }, + { conversationId: 'raw-private-chat-id' }, + ]) { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...forbidden, + }) + expect(response.status).toBe(400) + } + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the shared v2 auth error before parsing the body', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date(), + error: 'Invalid API key', + }) + + const response = await callChat({}) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockV2ApiGateError).not.toHaveBeenCalled() + }) + + it('stops local work, marks Go once, and retains the lease until the lifecycle settles', async () => { + const teardownOrder: string[] = [] + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockImplementationOnce(async () => { + teardownOrder.push('go-abort') + }) + mockReleasePendingChatStream.mockImplementationOnce(async () => { + teardownOrder.push('release') + }) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(teardownOrder).toEqual(['go-abort', 'release']) + expect(mockUnregisterActiveStream).toHaveBeenCalledTimes(1) + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { + mockRegisterActiveStream.mockImplementationOnce( + ( + _streamId: string, + _lifecycleController: AbortController, + userStopController: AbortController + ) => userStopController.abort('user_stop:abortActiveStream') + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'keep going' }) + expect(await response.text()).toBe('') + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('still stops local work and retains the lease when the Go abort marker fails', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('marker unavailable')) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'settled naturally', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts new file mode 100644 index 00000000000..cb99ea7ab07 --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.ts @@ -0,0 +1,691 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { MAX_V2_CHAT_BODY_BYTES, v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { createRunSegment } from '@/lib/copilot/async-runs/repository' +import { + getAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat, +} from '@/lib/copilot/chat/lifecycle' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' +import { + issueV2ChatContinuationToken, + verifyV2ChatContinuationToken, +} from '@/lib/copilot/headless/continuation-token' +import { + publicChatUsageLimitMessage, + runWorkspaceChat, + toPublicChatResult, +} from '@/lib/copilot/headless/workspace-chat' +import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' +import { fireTitleGeneration } from '@/lib/copilot/request/lifecycle/start' +import { + AbortReason, + acquirePendingChatStream, + cleanupAbortMarker, + clearFilePreviewSessions, + encodeSSEComment, + encodeSSEEnvelope, + registerActiveStream, + releasePendingChatStream, + resetBuffer, + SSE_RESPONSE_HEADERS, + StreamWriter, + scheduleBufferCleanup, + scheduleFilePreviewSessionCleanup, + startAbortPoller, + unregisterActiveStream, +} from '@/lib/copilot/request/session' +import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { env } from '@/lib/core/config/env' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { ChatActivityProjector, type V2ChatActivity } from '@/app/api/v2/chat/activity' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +export const maxDuration = 3600 +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const logger = createLogger('V2ChatAPI') +const encoder = new TextEncoder() +const HEARTBEAT_INTERVAL_MS = 15_000 +const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' +const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' + +interface SyncedChat { + chat: { title?: string | null } | null + isNewChat: boolean + mcpServerIds: string[] +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/** POST /api/v2/chat — normal workspace chat with opaque continuation over SSE. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let acquiredChatId: string | undefined + let acquiredStreamId: string | undefined + let streamOwnsLock = false + + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const authenticatedUserId = rateLimit.userId! + const gate = await v2ApiGateError(authenticatedUserId) + if (gate) return gate + + const parsed = await parseRequest( + v2ChatContract, + request, + {}, + { + maxBodyBytes: MAX_V2_CHAT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + invalidJsonResponse: () => + v2Error('BAD_REQUEST', 'Request body must be valid JSON', { + headers: rateLimitHeaders(rateLimit), + }), + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error( + 'PAYLOAD_TOO_LARGE', + `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + { headers: rateLimitHeaders(rateLimit) } + ) + : parsed.response + } + + const { workspaceId, prompt, continuationToken, readOnly, attachments, contexts } = + parsed.data.body + const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' + const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT + // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not + // an API key, so the workspace's personal-key toggle must not reject it. + // Real personal keys retain the normal toggle on every hosted path. + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess( + accessPrincipal, + authenticatedUserId, + workspaceId, + 'read' + ) + if (access) return v2WorkspaceAccessError(access) + + // Sim API keys authenticate this public boundary only. Every Sim -> Go + // request uses the deployment-owned key so hosted and self-hosted billing + // semantics cannot be changed by a caller-controlled credential. + if (!env.COPILOT_API_KEY?.trim()) { + return v2Error('SERVICE_UNAVAILABLE', 'Sim Chat is not configured on this deployment', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const continuation = continuationToken + ? await verifyV2ChatContinuationToken(continuationToken, { + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + }) + : null + if (continuation && !continuation.valid) { + return v2Error('BAD_REQUEST', 'Invalid or expired continuation token', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const shouldSyncChat = rateLimit.keyType === 'personal' + let continuedSyncedChat: SyncedChat | null = null + if (continuation?.valid) { + if (continuation.persistence === 'sim' && !shouldSyncChat) { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (shouldSyncChat) { + const existing = await getAccessibleCopilotChatContinuationMetadata( + continuation.chatId, + authenticatedUserId + ) + const matchesPersistedChat = + existing?.type === 'mothership' && existing.workspaceId === workspaceId + /** + * Tokens issued before Sim-side persistence can point at a Go-only + * chat. A deleted/missing row follows the same path: keep the valid + * continuation working, but do not create a partial UI transcript + * without its earlier turns. + */ + if (matchesPersistedChat && existing) { + continuedSyncedChat = { + chat: { title: existing.title }, + isNewChat: !existing.hasMessages, + mcpServerIds: existing.mcpServerIds, + } + } else if (continuation.persistence === 'sim') { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + } + } + + const preparedAttachments = prepareV2ChatAttachments(attachments) + if (!preparedAttachments.success) { + return v2Error(preparedAttachments.error.code, preparedAttachments.error.message, { + headers: rateLimitHeaders(rateLimit), + }) + } + + /** + * Match public workflow execution: a personal key identifies its human + * actor; a shared workspace key uses the atomically resolved system actor + * and payer. Authorization above always remains bound to the key owner. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: authenticatedUserId, workspaceId }) + const actorUserId = billingAttribution.actorUserId + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + { headers: rateLimitHeaders(rateLimit) } + ) + } + + let syncedChat = continuedSyncedChat + let chatId: string + + if (continuation?.valid) { + chatId = continuation.chatId + } else if (shouldSyncChat) { + const created = await resolveOrCreateChat({ + userId: authenticatedUserId, + workspaceId, + model: V2_CHAT_TITLE_MODEL, + type: 'mothership', + }) + if (!created.chat || !created.chatId) { + throw new Error('Failed to create persisted v2 chat') + } + syncedChat = { + chat: created.chat, + isNewChat: created.conversationHistory.length === 0, + mcpServerIds: [], + } + chatId = created.chatId + chatPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'created' }) + } else { + chatId = generateId() + } + + const messageId = generateId() + const executionId = syncedChat ? generateId() : undefined + const runId = syncedChat ? generateId() : undefined + const replayPublisher = syncedChat + ? new StreamWriter({ streamId: messageId, chatId, requestId }) + : null + const onTurnComplete = syncedChat + ? buildCopilotTurnOnComplete({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const onTurnError = syncedChat + ? buildCopilotTurnOnError({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const lifecycleAbortController = new AbortController() + const userStopController = new AbortController() + const chatStreamLockAcquired = await acquirePendingChatStream(chatId, messageId) + if (!chatStreamLockAcquired) { + return v2Error('CONFLICT', 'A response is already in progress for this chat', { + headers: rateLimitHeaders(rateLimit), + }) + } + acquiredChatId = chatId + acquiredStreamId = messageId + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const refreshedContinuationToken = await issueV2ChatContinuationToken({ + chatId, + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + ...(syncedChat ? { persistence: 'sim' as const } : {}), + }) + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + let cancelled = false + let publicStreamOpen = false + let lifecycleStarted = false + let abortRequested = false + let allowExplicitAbort = true + let explicitAbortRequest: Promise | undefined + + const requestExplicitAbortOnce = () => { + if (!lifecycleStarted || !allowExplicitAbort) return undefined + if (!explicitAbortRequest) { + explicitAbortRequest = requestExplicitStreamAbort({ + streamId: messageId, + // Go scopes the live stream to its execution/billing actor, while Sim + // must choose the upstream environment from the API-key owner. Keeping + // those identities separate prevents an actor override from rerouting + // Stop without breaking Go's owner-scoped abort marker. + userId: actorUserId, + routingUserId: authenticatedUserId, + chatId, + workspaceId, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to send explicit abort for v2 chat`, { + error: toError(error).message, + }) + }) + } + return explicitAbortRequest + } + + /** + * A disconnected public reader is an explicit stop request. Match the web + * UI's Stop path: stop Sim-side work, mark the detached Go execution, and + * keep draining the active Go leg so persistence settles before cleanup. + * The route owns the chat lease until that lifecycle has unwound. + */ + const abortLifecycle = () => { + abortRequested = true + requestExplicitAbortOnce() + if (allowExplicitAbort && !userStopController.signal.aborted) { + userStopController.abort(AbortReason.UserStop) + } + } + const onRequestAbort = () => abortLifecycle() + + if (request.signal.aborted) onRequestAbort() + else request.signal.addEventListener('abort', onRequestAbort, { once: true }) + + let heartbeatId: ReturnType | undefined + const stream = new ReadableStream({ + start(controller) { + publicStreamOpen = true + registerActiveStream(messageId, lifecycleAbortController, userStopController) + const abortPoller = startAbortPoller(messageId, lifecycleAbortController, { + requestId, + chatId, + userStopController, + }) + const send = (data: unknown): boolean => { + if (cancelled || !publicStreamOpen) return false + controller.enqueue(encodeSSEEnvelope(data)) + return true + } + const activityProjector = new ChatActivityProjector() + const sendActivities = (activities: V2ChatActivity[]) => { + for (const activity of activities) send({ type: 'activity', data: activity }) + } + + let sessionSent = false + let pendingTitle = syncedChat?.chat?.title?.trim() || undefined + let publishedTitle: string | undefined + let replayFinalized = false + let runSegmentPromise: Promise | undefined + const publishTitle = (title: string) => { + const next = title.trim() + if (!next) return + pendingTitle = next + if (!sessionSent || next === publishedTitle) return + if (send({ type: 'session', chatId, title: next })) publishedTitle = next + } + const sendSession = () => { + if (sessionSent) return + sessionSent = true + const sent = send({ + type: 'session', + continuationToken: refreshedContinuationToken, + requestId, + ...(syncedChat ? { chatId } : {}), + ...(pendingTitle ? { title: pendingTitle } : {}), + }) + if (sent && pendingTitle) publishedTitle = pendingTitle + } + heartbeatId = setInterval(() => { + if (!cancelled && publicStreamOpen) { + controller.enqueue(encodeSSEComment(`heartbeat ${new Date().toISOString()}`)) + } + }, HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + if (lifecycleAbortController.signal.aborted || userStopController.signal.aborted) { + return + } + + if (replayPublisher && syncedChat && executionId && runId) { + await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) + runSegmentPromise = createRunSegment({ + id: runId, + executionId, + chatId, + userId: authenticatedUserId, + workspaceId, + streamId: messageId, + model: null, + requestContext: { requestId, source: 'v2_chat' }, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { + error: getErrorMessage(error), + }) + }) + replayPublisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, + }) + await replayPublisher.flush() + await persistCopilotUserMessage({ + chatId, + userMessageId: messageId, + message: effectivePrompt, + contexts, + workspaceId, + notifyWorkspaceStatus: true, + }) + fireTitleGeneration({ + chatId, + currentChat: syncedChat.chat, + isNewChat: syncedChat.isNewChat, + userId: authenticatedUserId, + message: effectivePrompt, + titleModel: V2_CHAT_TITLE_MODEL, + workspaceId, + billingAttribution, + requestId, + publisher: { + publish(event) { + replayPublisher.publish(event) + if ( + event.type === MothershipStreamV1EventType.session && + event.payload.kind === MothershipStreamV1SessionKind.title + ) { + publishTitle(event.payload.title) + } + }, + }, + }) + } + + lifecycleStarted = true + if (abortRequested) requestExplicitAbortOnce() + const result = await runWorkspaceChat({ + prompt: effectivePrompt, + authorizationUserId: authenticatedUserId, + actorUserId, + workspaceId, + chatId, + messageId, + requestId, + executionId, + runId, + billingAttribution, + readOnly, + sharedWorkspaceCredential: credentialType === 'workspace', + fileAttachments: preparedAttachments.attachments, + contexts, + mcpServerIds: syncedChat?.mcpServerIds, + abortSignal: lifecycleAbortController.signal, + userStopSignal: userStopController.signal, + onInitialStreamAccepted: sendSession, + onEvent: async (event) => { + replayPublisher?.publish(event) + sendActivities(activityProjector.project(event)) + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + !event.scope && + event.payload.text + ) { + const text = event.payload.text + if (!publicChatUsageLimitMessage(text)) { + send({ type: 'text', delta: text }) + } + } + }, + onComplete: onTurnComplete, + onError: onTurnError, + }) + + if (replayPublisher && runId) { + await runSegmentPromise + const replayOutcome = result.success + ? RequestTraceV1Outcome.success + : result.cancelled || + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error + await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) + replayFinalized = true + } + + const upstreamUsageLimit = publicChatUsageLimitMessage(result.content) + if (upstreamUsageLimit) { + allowExplicitAbort = false + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'USAGE_LIMIT_EXCEEDED', + message: upstreamUsageLimit, + }, + }) + return + } + + if (!sessionSent) { + throw new Error('Mothership did not acknowledge the initial chat stream') + } + if ( + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + result.cancelled + ) { + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Chat request cancelled' }, + }) + return + } + + if (!result.success) { + requestExplicitAbortOnce() + logger.error(`[${requestId}] V2 chat failed`, { + workspaceId, + error: result.error, + errors: result.errors, + }) + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'INTERNAL_ERROR', + message: 'Chat request failed', + }, + }) + return + } + + allowExplicitAbort = false + + sendActivities(activityProjector.finish('complete')) + send({ + type: 'complete', + data: toPublicChatResult(result, refreshedContinuationToken), + }) + if (!cancelled) controller.enqueue(encoder.encode('data: [DONE]\n\n')) + publicStreamOpen = false + } catch (error) { + const aborted = + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + isAbortError(error) + const terminalResult: OrchestratorResult = { + success: false, + cancelled: aborted, + content: '', + contentBlocks: [], + toolCalls: [], + error: toError(error).message, + } + if (!replayFinalized) { + if (aborted) { + await onTurnComplete?.(terminalResult) + } else { + await onTurnError?.(toError(error), terminalResult) + } + if (replayPublisher && runId) { + try { + await runSegmentPromise + await finalizeStream( + terminalResult, + replayPublisher, + runId, + aborted ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error, + requestId + ) + replayFinalized = true + } catch (finalizeError) { + logger.warn(`[${requestId}] Failed to finalize v2 replay stream`, { + error: getErrorMessage(finalizeError), + }) + } + } + } + if (!aborted) { + logger.error(`[${requestId}] V2 chat error`, { + workspaceId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: aborted ? 'CLIENT_CLOSED_REQUEST' : 'INTERNAL_ERROR', + message: aborted ? 'Chat request cancelled' : 'Chat request failed', + }, + }) + } finally { + publicStreamOpen = false + allowExplicitAbort = false + if (heartbeatId) clearInterval(heartbeatId) + request.signal.removeEventListener('abort', onRequestAbort) + await explicitAbortRequest + clearInterval(abortPoller) + unregisterActiveStream(messageId) + await releasePendingChatStream(chatId, messageId) + await cleanupAbortMarker(messageId) + if (replayPublisher) { + try { + await replayPublisher.close() + } catch (error) { + logger.warn(`[${requestId}] Failed to flush v2 replay stream`, { + error: getErrorMessage(error), + }) + } + await scheduleBufferCleanup(messageId) + await scheduleFilePreviewSessionCleanup(messageId) + } + if (!cancelled) controller.close() + } + })() + }, + cancel(reason) { + cancelled = true + publicStreamOpen = false + if (heartbeatId) clearInterval(heartbeatId) + abortLifecycle() + }, + }) + streamOwnsLock = true + + return new Response(stream, { + headers: { + ...SSE_RESPONSE_HEADERS, + 'Cache-Control': 'private, no-store, no-transform', + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + if (!streamOwnsLock && acquiredChatId && acquiredStreamId) { + await releasePendingChatStream(acquiredChatId, acquiredStreamId) + } + logger.error(`[${requestId}] Failed to start v2 chat`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts new file mode 100644 index 00000000000..997739fa9a0 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts @@ -0,0 +1,372 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockGetAccessibleCopilotChatWithMessages, + mockIssueV2ChatContinuationToken, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockGetAccessibleCopilotChatWithMessages: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChatWithMessages, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET, PATCH } from '@/app/api/v2/chats/[chatId]/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + userId: 'user-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Release plan', + conversationId: 'stream-stale', + resources: null, + createdAt: new Date('2026-08-07T11:00:00.000Z'), + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + messages: [], + ...overrides, + } +} + +function callDetail(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats/chat-1?${query}`), { + params: Promise.resolve({ chatId: 'chat-1' }), + }) +} + +function callRename(body: Record) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/chats/chat-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chatId: 'chat-1' }) } + ) +} + +describe('GET /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(buildChat()) + mockReconcileChatStreamMarkers.mockResolvedValue( + new Map([['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }]]) + ) + mockIssueV2ChatContinuationToken.mockResolvedValue('continuation-token') + }) + + it('rejects workspace keys before loading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without loading the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + + const response = await callDetail() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it.each([ + ['an inaccessible chat', null], + ['a workflow-scoped chat', buildChat({ type: 'copilot' })], + ['a chat from another workspace', buildChat({ workspaceId: 'workspace-2' })], + ])('masks %s as the same not-found response', async (_case, chat) => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(chat) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) + + it('projects display-safe messages and reports the reconciled active marker', async () => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue( + buildChat({ + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + contexts: [{ kind: 'workflow', label: 'Release', workflowId: 'workflow-1' }], + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + requestId: 'request-private', + contentBlocks: [{ type: 'text', content: 'Done' }], + }, + { + id: 'message-system', + role: 'system', + content: 'private instructions', + timestamp: '2026-08-07T11:29:00.000Z', + }, + null, + ], + }) + ) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([['chat-1', { chatId: 'chat-1', streamId: 'stream-live', status: 'active' }]]) + ) + + const response = await callDetail('workspaceId=workspace-1&readOnly=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + id: 'chat-1', + title: 'Release plan', + active: true, + continuationToken: 'continuation-token', + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + }, + ], + }) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-1', streamId: 'stream-stale' }], + { repairVerifiedStaleMarkers: true } + ) + }) + + it.each([ + ['true', true], + ['false', false], + ])('binds readOnly=%s into the minted continuation token', async (raw, expected) => { + const response = await callDetail(`workspaceId=workspace-1&readOnly=${raw}`) + + expect(response.status).toBe(200) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'chat-1', + workspaceId: 'workspace-1', + authorizationUserId: 'user-1', + credentialType: 'personal', + readOnly: expected, + persistence: 'sim', + }) + }) +}) + +describe('PATCH /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('renames an owned chat and notifies the synchronized Home list', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1', workspaceId: 'workspace-1' }]) + + const response = await callRename({ + workspaceId: 'workspace-1', + title: 'Incident investigation', + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + title: 'Incident investigation', + updatedAt: expect.any(Date), + lastSeenAt: expect.any(Date), + }) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: 'chat-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: 'user-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.workspaceId, + right: 'workspace-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.type, + right: 'mothership', + }), + expect.objectContaining({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }), + ]) + ) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'renamed', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_renamed', + { workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) + }) + + it('rejects workspace keys before touching private chat data', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure before updating the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('masks missing, deleted, foreign, and non-mothership chats as not found', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.ts b/apps/sim/app/api/v2/chats/[chatId]/route.ts new file mode 100644 index 00000000000..16b09ac2bf8 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetChatContract, v2RenameChatContract } from '@/lib/api/contracts/v2/chats' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatWithMessages } from '@/lib/copilot/chat/lifecycle' +import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { issueV2ChatContinuationToken } from '@/lib/copilot/headless/continuation-token' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatDetailAPI') +type ChatRouteContext = { params: Promise<{ chatId: string }> } + +/** GET /api/v2/chats/[chatId] — open one owned chat and mint a fresh resume token. */ +export const GET = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest(v2GetChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, readOnly } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + if (!chat || chat.type !== 'mothership' || chat.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Chat not found') + } + + const streamMarkers = await reconcileChatStreamMarkers( + [{ chatId: chat.id, streamId: chat.conversationId }], + { repairVerifiedStaleMarkers: true } + ) + const active = Boolean(streamMarkers.get(chat.id)?.streamId) + const continuationToken = await issueV2ChatContinuationToken({ + chatId: chat.id, + workspaceId, + authorizationUserId: userId, + credentialType: 'personal', + readOnly, + persistence: 'sim', + }) + const messages = (Array.isArray(chat.messages) ? chat.messages : []) + .filter((message): message is Record => Boolean(message)) + .map(normalizeMessage) + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map(({ id, role, content, timestamp }) => ({ id, role, content, timestamp })) + + return v2Data( + { + id: chat.id, + title: chat.title, + messages, + continuationToken, + active, + }, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to open v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/chats/[chatId] — rename one owned workspace chat. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Renaming chats requires a personal API key') + } + + const parsed = await parseRequest(v2RenameChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, title } = parsed.data.body + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const now = new Date() + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: now, lastSeenAt: now }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!updated) return v2Error('NOT_FOUND', 'Chat not found') + + if (updated.workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId: updated.workspaceId, + chatId: updated.id, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updated.workspaceId }, + { groups: { workspace: updated.workspaceId } } + ) + } + + return v2Data({ id: updated.id, title }, { rateLimit }) + } catch (error) { + logger.error('Failed to rename v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/route.test.ts b/apps/sim/app/api/v2/chats/route.test.ts new file mode 100644 index 00000000000..b59eed21878 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET } from '@/app/api/v2/chats/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + title: 'Release plan', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + pinned: true, + activeStreamId: 'stream-stale', + ...overrides, + } +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats?${query}`)) +} + +describe('GET /api/v2/chats', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + }) + + it('rejects workspace keys before reading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without querying chats', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + queueTableRows(schemaMock.copilotChats, []) + + const response = await callList() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('bounds the SQL page, maps summaries, and derives active state from the live marker', async () => { + queueTableRows(schemaMock.copilotChats, [ + buildChat(), + buildChat({ + id: 'chat-2', + title: null, + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: 'stream-live', + }), + buildChat({ id: 'chat-3' }), + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }], + ['chat-2', { chatId: 'chat-2', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await callList('workspaceId=workspace-1&limit=2') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + id: 'chat-1', + title: 'Release plan', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: false, + }, + { + id: 'chat-2', + title: null, + updatedAt: '2026-08-06T12:00:00.000Z', + pinned: false, + active: true, + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-1', streamId: 'stream-stale' }, + { chatId: 'chat-2', streamId: 'stream-live' }, + ], + { repairVerifiedStaleMarkers: true } + ) + }) + + it('replays its opaque cursor as a keyset bound', async () => { + queueTableRows(schemaMock.copilotChats, [buildChat(), buildChat({ id: 'chat-2' })]) + const first = await callList('workspaceId=workspace-1&limit=1') + const { nextCursor } = await first.json() + + queueTableRows(schemaMock.copilotChats, [ + buildChat({ + id: 'chat-2', + title: 'Older chat', + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: null, + }), + ]) + const second = await callList( + `workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions.some((condition) => condition?.type === 'or')).toBe(true) + }) + + it('rejects a malformed cursor instead of restarting at the first page', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts new file mode 100644 index 00000000000..667f5c788f4 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.ts @@ -0,0 +1,139 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2ChatSummary, v2ListChatsContract } from '@/lib/api/contracts/v2/chats' +import { + encodeKeyset, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatsAPI') +const CHAT_SORT = 'pinned:desc,updatedAt:desc' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type ChatRow = { + id: string + title: string | null + updatedAt: Date + pinned: boolean + activeStreamId: string | null +} + +const pinnedRank = sql`case when ${copilotChats.pinned} then 1 else 0 end` +const CHAT_KEYS = [ + numberKey(pinnedRank, (row) => (row.pinned ? 1 : 0)), + timestampKey(copilotChats.updatedAt, (row) => row.updatedAt), + textKey(copilotChats.id, (row) => row.id), +] + +/** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + // A workspace key can be held by people other than its creator. Its + // creator's UI chats are private and must never become shared-key data. + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest( + v2ListChatsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, search, limit, cursor } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = decodeSortedCursor(cursor, CHAT_SORT) + if (decoded.status === 'invalid') return v2CursorSortError() + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(CHAT_KEYS, decoded.keys, 'desc') : undefined + if (resumeAfter === null) return v2CursorSortError() + + const rows = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + updatedAt: copilotChats.updatedAt, + pinned: copilotChats.pinned, + activeStreamId: copilotChats.conversationId, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt), + searchFilter(copilotChats.title, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(CHAT_KEYS), 'desc')) + .limit(limit + 1) + + const page = rows.slice(0, limit) + const streamMarkers = await reconcileChatStreamMarkers( + page.map((chat) => ({ chatId: chat.id, streamId: chat.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + const data: V2ChatSummary[] = page.map((chat) => ({ + id: chat.id, + title: chat.title, + updatedAt: chat.updatedAt.toISOString(), + pinned: chat.pinned, + active: Boolean(streamMarkers.get(chat.id)?.streamId), + })) + + const last = page.at(-1) + const nextCursor = + rows.length > limit && last + ? encodeSortedCursor(CHAT_SORT, encodeKeyset(CHAT_KEYS, last)) + : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Failed to list v2 chats', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index f13d1e0cffa..57a849b97b0 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -1,3 +1,5 @@ +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' + /** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ @@ -12,6 +14,10 @@ export interface CliAuthRequest { challenge: string /** Printed by the CLI, rendered for eyeball comparison. Never sent to the API. */ pairing: string + /** Which key space the terminal is asking for. */ + scope: CliAuthScope + /** Workspace the terminal suggests preselecting. A hint only — never authority. */ + suggestedWorkspaceId: string | null } export type CliAuthRequestResolution = @@ -22,6 +28,8 @@ interface RawCliAuthParams { request: string | null challenge: string | null pairing: string | null + scope: CliAuthScope + workspace: string | null } /** @@ -32,6 +40,8 @@ export function resolveCliAuthRequest({ request, challenge, pairing, + scope, + workspace, }: RawCliAuthParams): CliAuthRequestResolution { if (!request || !challenge || !pairing) { return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } @@ -45,5 +55,8 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'The pairing code is malformed.' } } - return { valid: true, request: { request, challenge, pairing } } + return { + valid: true, + request: { request, challenge, pairing, scope, suggestedWorkspaceId: workspace || null }, + } } diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx new file mode 100644 index 00000000000..c2f4e9b6005 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,139 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockApprove, mockUseWorkspaces, mockPush } = vi.hoisted(() => ({ + mockApprove: vi.fn(), + mockUseWorkspaces: vi.fn(), + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [ + { + request: 'a'.repeat(43), + challenge: 'b'.repeat(43), + pairing: 'ABCD-2345', + scope: 'platform', + workspace: null, + }, + ], +})) + +vi.mock('@/hooks/queries/cli-auth', () => ({ + useApproveCliAuth: () => ({ + mutate: mockApprove, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), +})) + +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspaces, +})) + +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' + +let container: HTMLDivElement +let root: Root + +function render() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) +} + +/** The primary CTA is the only button whose label mentions connecting. */ +function connectButton(): HTMLButtonElement { + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const button = buttons.find((b) => /connect/i.test(b.textContent ?? '')) + if (!button) throw new Error('Connect button not found') + return button +} + +const LOADED = { + isPending: false, + isError: false, + data: { + workspaces: [ + { id: 'ws_admin', name: 'Acme', permissions: 'admin' }, + { id: 'ws_member', name: 'Other', permissions: 'write' }, + ], + lastActiveWorkspaceId: 'ws_admin', + }, +} + +describe('CliAuthView workspace loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Connect until the workspace list resolves', () => { + // The regression: while pending, the picker falls back to no default, so an + // early click saved no workspace when the same click a moment later would + // have saved the user's last active workspace. + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(connectButton().disabled).toBe(true) + expect(container.textContent).toContain('Loading workspaces') + expect(container.textContent).not.toContain('No default workspace') + }) + + it('does not present a workspace choice as final while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Loading your workspace options') + expect(container.textContent).not.toContain('Issues a personal key') + }) + + it('enables Connect and preselects the last active workspace once loaded', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') + }) + + it('issues a personal key even when the approver is a workspace admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: false, + }), + expect.anything() + ) + }) + + it('still lets the user connect when the workspace list fails', () => { + // A personal key is degraded but usable; blocking entirely would strand a + // terminal on a transient list failure. + mockUseWorkspaces.mockReturnValue({ isPending: false, isError: true, data: undefined }) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Could not load your workspaces') + }) +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 96ad0648ffc..f7865af95da 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -1,5 +1,7 @@ 'use client' +import { useMemo, useState } from 'react' +import { ChipSelect, type ChipSelectOption, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -7,6 +9,10 @@ import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/comp import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' + +/** Sentinel for the "no default workspace" row; an empty string reads as unselected. */ +const NO_DEFAULT_WORKSPACE_VALUE = '__no_default_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -22,8 +28,20 @@ export function CliAuthView() { const router = useRouter() const [params] = useQueryStates(cliAuthParsers) const approve = useApproveCliAuth() + const [selected, setSelected] = useState(null) const resolution = resolveCliAuthRequest(params) + const isPlatform = resolution.valid && resolution.request.scope === 'platform' + + const workspaces = useWorkspacesWithMetadata(isPlatform) + + const options = useMemo(() => { + const rows: ChipSelectOption[] = (workspaces.data?.workspaces ?? []).map((workspace) => ({ + label: workspace.name, + value: workspace.id, + })) + return [...rows, { label: 'No default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,34 @@ export function CliAuthView() { const { request } = resolution + /** + * Approval must wait for the workspace list. + * + * Until it arrives there is no selection to show, and the fallback would read + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click save no default when a + * moment later the same click would have saved the user's workspace. Blocking + * is the only way the card can promise what it is about to configure. + */ + const loadingWorkspaces = isPlatform && workspaces.isPending + + /** + * The terminal's suggestion, then the user's last active workspace. Derived at + * render rather than synced into state through an effect, so the first paint + * after the list loads already shows the right row. + * + * The suggestion only counts when it resolves to a workspace the user + * actually has. It comes from a profile the CLI wrote earlier, so it can name + * a workspace they have since left or one that no longer exists — and being + * merely truthy, it used to shadow the last-active fallback and leave the card + * on "no workspace" with a perfectly good one available. + */ + const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId) + ? request.suggestedWorkspaceId + : null + const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null + const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {loadingWorkspaces + ? 'Loading your workspace options…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : chosen + ? `Issues a personal key tied to your account and makes ${chosen.name} the CLI default.` + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // The picked workspace is only the terminal's default. Login + // always mints a personal key so the profile can switch workspaces. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: false, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..abc058f7704 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -48,7 +48,11 @@ export default async function CliAuthPage({ request: resolution.request.request, challenge: resolution.request.challenge, pairing: resolution.request.pairing, + scope: resolution.request.scope, }) + if (resolution.request.suggestedWorkspaceId) { + query.set('workspace', resolution.request.suggestedWorkspaceId) + } redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index e62b286e594..375a1c77ab3 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,16 +1,27 @@ -import { createSearchParamsCache, parseAsString } from 'nuqs/server' +import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ +export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const /** * Co-located, typed URL query params for the CLI key handoff. Read-only for the * life of the page, so there is no `urlKeys` companion. * - * Nullable with no defaults: a missing value is an invalid request, not a state - * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + * `request`/`challenge`/`pairing` are nullable with no defaults: a missing value + * is an invalid request, not a state to fall back from. `resolveCliAuthRequest` + * validates them; never trusted as-is. + * + * `scope` defaults to `copilot` so a terminal built against the original handoff + * — which sent no scope — still lands on the key space it expects. `workspace` + * is only a preselection hint for the picker; the workspace that ends up bound + * to the key is the one the user confirms, and it is re-authorized server-side. */ export const cliAuthParsers = { request: parseAsString, challenge: parseAsString, pairing: parseAsString, + scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), + workspace: parseAsString, } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 3dde6afcb0f..7a4647f8bfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -11,15 +11,11 @@ import { useState, } from 'react' import { - type DesktopAppearanceTheme, type DesktopZoomAction, type DesktopZoomPercent, resolveDesktopZoom, - TERMINAL_DARK_THEME, - TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, type TerminalShortcutCommand, - type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { @@ -45,7 +41,8 @@ import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, - resolveDesktopAppearanceTheme, + refreshSelectedTerminalProfile, + resolveTerminalThemePalette, withSelectedProfile, } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' @@ -314,15 +311,7 @@ const TerminalView = memo(function TerminalView({ defaultZoom: DesktopZoomPercent }) { const { resolvedTheme } = useTheme() - const profileTheme = typeof appearanceTheme === 'string' ? undefined : appearanceTheme - const builtInTheme: DesktopAppearanceTheme = - typeof appearanceTheme === 'string' ? appearanceTheme : 'app' - const colorScheme = resolveDesktopAppearanceTheme(builtInTheme, resolvedTheme) - const terminalTheme: TerminalThemePalette = profileTheme - ? profileTheme.palette - : colorScheme === 'dark' - ? TERMINAL_DARK_THEME - : TERMINAL_LIGHT_THEME + const terminalTheme = resolveTerminalThemePalette(appearanceTheme, resolvedTheme) const hostRef = useRef(null) const terminalRef = useRef(null) const fitRef = useRef(null) @@ -756,26 +745,20 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { ) useEffect(() => { + if (!visible) return let active = true - void loadDesktopTerminalAppearance().then((next) => { - if (!active) return - setAppearanceTheme(next.theme) - setDefaultZoom(next.defaultZoom) - }) - return () => { - active = false - } - }, []) - - useEffect(() => { - let active = true - void loadDesktopTerminalThemeProfiles().then((next) => { - if (active) setProfiles(next) - }) + void Promise.all([loadDesktopTerminalAppearance(), loadDesktopTerminalThemeProfiles()]).then( + ([nextAppearance, nextProfiles]) => { + if (!active) return + setProfiles(nextProfiles) + setAppearanceTheme(refreshSelectedTerminalProfile(nextProfiles, nextAppearance.theme)) + setDefaultZoom(nextAppearance.defaultZoom) + } + ) return () => { active = false } - }, []) + }, [visible]) useEffect(() => { let active = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5c7684320dd..7faf2135da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -24,6 +24,7 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { prefersInPlaceNavigation } from '@/lib/desktop' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -73,6 +74,25 @@ const LOADING_SKELETON = ( ) +/** + * Opens an internal app link the way the host expects: a new browser tab on the + * web, and the current view in the desktop app, whose shell would otherwise turn + * the same-origin `window.open` into a second Sim window. + */ +function useOpenInternalLink() { + const router = useRouter() + return useCallback( + (href: string) => { + if (prefersInPlaceNavigation()) { + router.push(href) + return + } + window.open(href, '_blank') + }, + [router] + ) +} + interface ResourceContentProps { workspaceId: string desktopScopeId: string @@ -350,6 +370,7 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { + const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -404,7 +425,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + openInternalLink(`/workspace/${workspaceId}/w/${workflowId}`) } return ( @@ -727,6 +748,7 @@ interface EmbeddedFolderProps { } function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { + const openInternalLink = useOpenInternalLink() const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) const { data: workflowList = [] } = useWorkflows(workspaceId) @@ -760,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {