chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export function getMothershipAttachmentPreviewUrl(file: {
|
||||
key: string
|
||||
media_type: string
|
||||
}): string | undefined {
|
||||
if (!file.media_type.startsWith('image/') && !file.media_type.startsWith('video/')) {
|
||||
return undefined
|
||||
}
|
||||
return `/api/files/serve/${encodeURIComponent(file.key)}?context=mothership`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toDisplayMessage } from './display-message'
|
||||
|
||||
describe('display-message', () => {
|
||||
it('maps canonical tool, subagent text, and cancelled complete blocks to display blocks', () => {
|
||||
const display = toDisplayMessage({
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'done',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
requestId: 'req-1',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
state: 'cancelled',
|
||||
display: { title: 'Stopped by user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
lane: 'subagent',
|
||||
channel: 'assistant',
|
||||
content: 'subagent output',
|
||||
},
|
||||
{
|
||||
type: 'complete',
|
||||
status: 'cancelled',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(display.contentBlocks).toEqual([
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
status: 'cancelled',
|
||||
displayTitle: 'Stopped by user',
|
||||
params: undefined,
|
||||
calledBy: undefined,
|
||||
result: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'subagent_text',
|
||||
content: 'subagent output',
|
||||
},
|
||||
{
|
||||
type: 'stopped',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('hides load_agent_skill blocks from display output', () => {
|
||||
const display = toDisplayMessage({
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-hidden',
|
||||
name: 'load_agent_skill',
|
||||
state: 'success',
|
||||
display: { title: 'Loading skill' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
channel: 'assistant',
|
||||
content: 'visible text',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(display.contentBlocks).toEqual([{ type: 'text', content: 'visible text' }])
|
||||
})
|
||||
|
||||
it('preserves skipped and rejected tool outcomes', () => {
|
||||
const display = toDisplayMessage({
|
||||
id: 'msg-3',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-skipped',
|
||||
name: 'read',
|
||||
state: 'skipped',
|
||||
display: { title: 'Reading workflow' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-rejected',
|
||||
name: 'run_workflow',
|
||||
state: 'rejected',
|
||||
display: { title: 'Running workflow' },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(display.contentBlocks).toEqual([
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-skipped',
|
||||
name: 'read',
|
||||
status: 'skipped',
|
||||
displayTitle: 'Reading workflow',
|
||||
params: undefined,
|
||||
calledBy: undefined,
|
||||
result: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-rejected',
|
||||
name: 'run_workflow',
|
||||
status: 'rejected',
|
||||
displayTitle: 'Running workflow',
|
||||
params: undefined,
|
||||
calledBy: undefined,
|
||||
result: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1SpanLifecycleEvent,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
|
||||
import {
|
||||
type ChatContextKind,
|
||||
type ChatMessage,
|
||||
type ChatMessageAttachment,
|
||||
type ChatMessageContext,
|
||||
type ContentBlock,
|
||||
ContentBlockType,
|
||||
type ToolCallInfo,
|
||||
ToolCallStatus,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { getMothershipAttachmentPreviewUrl } from './attachment-preview'
|
||||
import type { PersistedContentBlock, PersistedMessage } from './persisted-message'
|
||||
import { withBlockTiming } from './persisted-message'
|
||||
|
||||
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
[MothershipStreamV1ToolOutcome.success]: ToolCallStatus.success,
|
||||
[MothershipStreamV1ToolOutcome.error]: ToolCallStatus.error,
|
||||
[MothershipStreamV1ToolOutcome.cancelled]: ToolCallStatus.cancelled,
|
||||
[MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.rejected,
|
||||
[MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.skipped,
|
||||
aborted: ToolCallStatus.cancelled,
|
||||
failed: ToolCallStatus.error,
|
||||
interrupted: ToolCallStatus.interrupted,
|
||||
pending: ToolCallStatus.executing,
|
||||
executing: ToolCallStatus.executing,
|
||||
}
|
||||
|
||||
function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined {
|
||||
const tc = block.toolCall
|
||||
if (!tc) return undefined
|
||||
if (isToolHiddenInUi(tc.name)) return undefined
|
||||
const status: ToolCallStatus = STATE_TO_STATUS[tc.state] ?? ToolCallStatus.error
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
status,
|
||||
displayTitle: status === ToolCallStatus.cancelled ? 'Stopped by user' : tc.display?.title,
|
||||
params: tc.params,
|
||||
calledBy: tc.calledBy,
|
||||
result: tc.result,
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined {
|
||||
const displayed = toDisplayBlockBody(block)
|
||||
if (!displayed) return undefined
|
||||
if (block.parentToolCallId && displayed.parentToolCallId === undefined) {
|
||||
displayed.parentToolCallId = block.parentToolCallId
|
||||
}
|
||||
if (block.spanId && displayed.spanId === undefined) {
|
||||
displayed.spanId = block.spanId
|
||||
}
|
||||
if (block.parentSpanId && displayed.parentSpanId === undefined) {
|
||||
displayed.parentSpanId = block.parentSpanId
|
||||
}
|
||||
return withBlockTiming(displayed, block)
|
||||
}
|
||||
|
||||
function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
case MothershipStreamV1EventType.text:
|
||||
if (block.lane === 'subagent') {
|
||||
if (block.channel === 'thinking') {
|
||||
return { type: ContentBlockType.subagent_thinking, content: block.content }
|
||||
}
|
||||
return { type: ContentBlockType.subagent_text, content: block.content }
|
||||
}
|
||||
if (block.channel === 'thinking') {
|
||||
return { type: ContentBlockType.thinking, content: block.content }
|
||||
}
|
||||
return { type: ContentBlockType.text, content: block.content }
|
||||
case MothershipStreamV1EventType.tool:
|
||||
if (!toToolCallInfo(block)) return undefined
|
||||
return { type: ContentBlockType.tool_call, toolCall: toToolCallInfo(block) }
|
||||
case MothershipStreamV1EventType.span:
|
||||
if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) {
|
||||
return { type: ContentBlockType.subagent_end }
|
||||
}
|
||||
return { type: ContentBlockType.subagent, content: block.content }
|
||||
case MothershipStreamV1EventType.complete:
|
||||
if (block.status === MothershipStreamV1CompletionStatus.cancelled) {
|
||||
return { type: ContentBlockType.stopped }
|
||||
}
|
||||
return { type: ContentBlockType.text, content: block.content }
|
||||
default:
|
||||
return { type: ContentBlockType.text, content: block.content }
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayAttachment(f: PersistedMessage['fileAttachments']): ChatMessageAttachment[] {
|
||||
if (!f || f.length === 0) return []
|
||||
return f.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
media_type: a.media_type,
|
||||
size: a.size,
|
||||
previewUrl: getMothershipAttachmentPreviewUrl(a),
|
||||
}))
|
||||
}
|
||||
|
||||
function toDisplayContexts(
|
||||
contexts: PersistedMessage['contexts']
|
||||
): ChatMessageContext[] | undefined {
|
||||
if (!contexts || contexts.length === 0) return undefined
|
||||
return contexts.map((c) => ({
|
||||
kind: c.kind as ChatContextKind,
|
||||
label: c.label,
|
||||
...(c.workflowId ? { workflowId: c.workflowId } : {}),
|
||||
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
|
||||
...(c.tableId ? { tableId: c.tableId } : {}),
|
||||
...(c.fileId ? { fileId: c.fileId } : {}),
|
||||
...(c.folderId ? { folderId: c.folderId } : {}),
|
||||
...(c.chatId ? { chatId: c.chatId } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
const WORKSPACE_FILE_TOOL = 'workspace_file'
|
||||
const EDIT_CONTENT_TOOL = 'edit_content'
|
||||
const MAIN_SPAN = 'main'
|
||||
|
||||
/**
|
||||
* Collapses an `edit_content` write into the most-recent `workspace_file` row in
|
||||
* the same subagent span, mirroring the live turn-model fold. The live view
|
||||
* folds these in `reduceEvent`, but the persisted transcript stores them as two
|
||||
* separate tool blocks; without this a reloaded chat splits the file write into
|
||||
* "workspace_file" + "edit_content" rows (and a refresh mid-write leaves the
|
||||
* second row spinning). The reopened row inherits the edit_content's final
|
||||
* status/result, exactly as the live single "writing" row resolves. Every other
|
||||
* block is passed through untouched, so this only affects file writes.
|
||||
*/
|
||||
function foldFileWriteBlocks(blocks: ContentBlock[]): ContentBlock[] {
|
||||
const folded: ContentBlock[] = []
|
||||
const workspaceFileIndexBySpan = new Map<string, number>()
|
||||
for (const block of blocks) {
|
||||
const tc = block.type === ContentBlockType.tool_call ? block.toolCall : undefined
|
||||
if (tc) {
|
||||
const span = block.spanId ?? MAIN_SPAN
|
||||
if (tc.name === EDIT_CONTENT_TOOL) {
|
||||
const parentIndex = workspaceFileIndexBySpan.get(span)
|
||||
const parent = parentIndex !== undefined ? folded[parentIndex] : undefined
|
||||
if (parent?.type === ContentBlockType.tool_call && parent.toolCall) {
|
||||
folded[parentIndex!] = {
|
||||
...parent,
|
||||
toolCall: { ...parent.toolCall, status: tc.status, result: tc.result },
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else if (tc.name === WORKSPACE_FILE_TOOL) {
|
||||
workspaceFileIndexBySpan.set(span, folded.length)
|
||||
}
|
||||
}
|
||||
folded.push(block)
|
||||
}
|
||||
return folded
|
||||
}
|
||||
|
||||
const displayMessageCache = new WeakMap<PersistedMessage, ChatMessage>()
|
||||
|
||||
/**
|
||||
* Maps a `PersistedMessage` (server wire shape) to a `ChatMessage` (UI shape).
|
||||
* Reference-stable: returns the same object for a given `PersistedMessage`
|
||||
* instance so `React.memo` boundaries downstream of React Query's structural
|
||||
* sharing can short-circuit on identity.
|
||||
*/
|
||||
export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
|
||||
const cached = displayMessageCache.get(msg)
|
||||
if (cached) return cached
|
||||
|
||||
const display: ChatMessage = {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}
|
||||
|
||||
if (msg.requestId) {
|
||||
display.requestId = msg.requestId
|
||||
}
|
||||
|
||||
if (msg.contentBlocks && msg.contentBlocks.length > 0) {
|
||||
const displayBlocks = msg.contentBlocks
|
||||
.map(toDisplayBlock)
|
||||
.filter((block): block is ContentBlock => !!block)
|
||||
display.contentBlocks = foldFileWriteBlocks(displayBlocks)
|
||||
}
|
||||
|
||||
const attachments = toDisplayAttachment(msg.fileAttachments)
|
||||
if (attachments.length > 0) {
|
||||
display.attachments = attachments
|
||||
}
|
||||
|
||||
display.contexts = toDisplayContexts(msg.contexts)
|
||||
|
||||
displayMessageCache.set(msg, display)
|
||||
return display
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildEffectiveChatTranscript,
|
||||
getLiveAssistantMessageId,
|
||||
} from '@/lib/copilot/chat/effective-transcript'
|
||||
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1SessionKind,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
|
||||
function toBatchEvent(eventId: number, event: StreamBatchEvent['event']): StreamBatchEvent {
|
||||
return {
|
||||
eventId,
|
||||
streamId: event.stream.streamId,
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
function buildUserMessage(id: string, content: string) {
|
||||
return normalizeMessage({
|
||||
id,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: '2026-04-15T12:00:00.000Z',
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildEffectiveChatTranscript', () => {
|
||||
it('returns the existing transcript when the stream owner is no longer the trailing user', () => {
|
||||
const messages = [
|
||||
buildUserMessage('stream-1', 'Hello'),
|
||||
normalizeMessage({
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Persisted response',
|
||||
timestamp: '2026-04-15T12:00:01.000Z',
|
||||
}),
|
||||
]
|
||||
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages,
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: {
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
text: 'Live response',
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
it('appends a placeholder assistant while an active stream has not produced text yet', () => {
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages: [buildUserMessage('stream-1', 'Hello')],
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.session,
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: {
|
||||
kind: MothershipStreamV1SessionKind.start,
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: getLiveAssistantMessageId('stream-1'),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('materializes a live assistant response from redis-backed stream events', () => {
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages: [buildUserMessage('stream-1', 'Hello')],
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.session,
|
||||
stream: { streamId: 'stream-1' },
|
||||
trace: { requestId: 'req-1' },
|
||||
payload: {
|
||||
kind: MothershipStreamV1SessionKind.trace,
|
||||
requestId: 'req-1',
|
||||
},
|
||||
}),
|
||||
toBatchEvent(2, {
|
||||
v: 1,
|
||||
seq: 2,
|
||||
ts: '2026-04-15T12:00:02.000Z',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
stream: { streamId: 'stream-1' },
|
||||
trace: { requestId: 'req-1' },
|
||||
payload: {
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
text: 'Live response',
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: getLiveAssistantMessageId('stream-1'),
|
||||
role: 'assistant',
|
||||
content: 'Live response',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate thinking-only text into a second assistant block', () => {
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages: [buildUserMessage('stream-1', 'Hello')],
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: {
|
||||
channel: MothershipStreamV1TextChannel.thinking,
|
||||
text: 'Internal reasoning',
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
content: 'Internal reasoning',
|
||||
contentBlocks: [
|
||||
expect.objectContaining({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
content: 'Internal reasoning',
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('treats user-cancelled tool results as cancelled', () => {
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages: [buildUserMessage('stream-1', 'Hello')],
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: {
|
||||
phase: 'result',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
executor: 'go',
|
||||
mode: 'sync',
|
||||
success: false,
|
||||
output: {
|
||||
reason: 'user_cancelled',
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result[1]?.contentBlocks).toEqual([
|
||||
expect.objectContaining({
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
toolCall: expect.objectContaining({
|
||||
id: 'tool-1',
|
||||
name: 'workspace_file',
|
||||
state: MothershipStreamV1CompletionStatus.cancelled,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes a cancelled assistant tail when the stream ends before persistence', () => {
|
||||
const result = buildEffectiveChatTranscript({
|
||||
messages: [buildUserMessage('stream-1', 'Hello')],
|
||||
activeStreamId: 'stream-1',
|
||||
streamSnapshot: {
|
||||
events: [
|
||||
toBatchEvent(1, {
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: '2026-04-15T12:00:01.000Z',
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: {
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
},
|
||||
}),
|
||||
],
|
||||
previewSessions: [],
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]?.contentBlocks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,476 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
type MothershipStreamV1ErrorPayload,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1RunKind,
|
||||
MothershipStreamV1SessionKind,
|
||||
MothershipStreamV1SpanLifecycleEvent,
|
||||
MothershipStreamV1SpanPayloadKind,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
MothershipStreamV1ToolPhase,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
|
||||
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display'
|
||||
|
||||
interface StreamSnapshotLike {
|
||||
events: StreamBatchEvent[]
|
||||
previewSessions: FilePreviewSession[]
|
||||
status: string
|
||||
}
|
||||
|
||||
interface BuildEffectiveChatTranscriptParams {
|
||||
messages: PersistedMessage[]
|
||||
activeStreamId: string | null
|
||||
streamSnapshot?: StreamSnapshotLike | null
|
||||
}
|
||||
|
||||
type RawPersistedBlock = Record<string, unknown>
|
||||
|
||||
export function getLiveAssistantMessageId(streamId: string): string {
|
||||
return `live-assistant:${streamId}`
|
||||
}
|
||||
|
||||
function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecordLike(value) ? value : undefined
|
||||
}
|
||||
|
||||
function isTerminalStreamStatus(status: string | null | undefined): boolean {
|
||||
return (
|
||||
status === MothershipStreamV1CompletionStatus.complete ||
|
||||
status === MothershipStreamV1CompletionStatus.error ||
|
||||
status === MothershipStreamV1CompletionStatus.cancelled
|
||||
)
|
||||
}
|
||||
|
||||
function buildInlineErrorTag(payload: MothershipStreamV1ErrorPayload): string {
|
||||
const message =
|
||||
(typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) ||
|
||||
(typeof payload.message === 'string' ? payload.message : undefined) ||
|
||||
(typeof payload.error === 'string' ? payload.error : undefined) ||
|
||||
'An unexpected error occurred'
|
||||
const provider = typeof payload.provider === 'string' ? payload.provider : undefined
|
||||
const code = typeof payload.code === 'string' ? payload.code : undefined
|
||||
return `<mothership-error>${JSON.stringify({
|
||||
message,
|
||||
...(code ? { code } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
})}</mothership-error>`
|
||||
}
|
||||
|
||||
function appendTextBlock(
|
||||
blocks: RawPersistedBlock[],
|
||||
content: string,
|
||||
options: {
|
||||
lane?: 'subagent'
|
||||
parentToolCallId?: string
|
||||
spanId?: string
|
||||
parentSpanId?: string
|
||||
}
|
||||
): void {
|
||||
if (!content) return
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (
|
||||
last?.type === MothershipStreamV1EventType.text &&
|
||||
last.lane === options.lane &&
|
||||
last.parentToolCallId === options.parentToolCallId &&
|
||||
last.spanId === options.spanId
|
||||
) {
|
||||
last.content = `${typeof last.content === 'string' ? last.content : ''}${content}`
|
||||
return
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
...(options.lane ? { lane: options.lane } : {}),
|
||||
...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}),
|
||||
...(options.spanId ? { spanId: options.spanId } : {}),
|
||||
...(options.parentSpanId ? { parentSpanId: options.parentSpanId } : {}),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
function buildLiveAssistantMessage(params: {
|
||||
streamId: string
|
||||
events: StreamBatchEvent[]
|
||||
status: string | null | undefined
|
||||
}): PersistedMessage | null {
|
||||
const { streamId, events, status } = params
|
||||
const blocks: RawPersistedBlock[] = []
|
||||
const toolIndexById = new Map<string, number>()
|
||||
const subagentByParentToolCallId = new Map<string, string>()
|
||||
const subagentBySpanId = new Map<string, string>()
|
||||
let activeSubagent: string | undefined
|
||||
let activeSubagentParentToolCallId: string | undefined
|
||||
let activeCompactionId: string | undefined
|
||||
let runningText = ''
|
||||
let lastContentSource: 'main' | 'subagent' | null = null
|
||||
let requestId: string | undefined
|
||||
let lastTimestamp: string | undefined
|
||||
|
||||
// Scope-only resolution (mirrors the live browser stream loop): with
|
||||
// concurrent subagents the legacy activeSubagent fallback / name-match scan
|
||||
// would mis-attribute interleaved replayed events to the wrong lane.
|
||||
const resolveScopedSubagent = (
|
||||
agentId: string | undefined,
|
||||
parentToolCallId: string | undefined,
|
||||
spanId?: string
|
||||
): string | undefined => {
|
||||
if (agentId) return agentId
|
||||
if (spanId) {
|
||||
const scoped = subagentBySpanId.get(spanId)
|
||||
if (scoped) return scoped
|
||||
}
|
||||
if (parentToolCallId) {
|
||||
const scoped = subagentByParentToolCallId.get(parentToolCallId)
|
||||
if (scoped) return scoped
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolveParentForSubagentBlock = (
|
||||
subagent: string | undefined,
|
||||
scopedParent: string | undefined
|
||||
): string | undefined => {
|
||||
if (!subagent) return undefined
|
||||
return scopedParent
|
||||
}
|
||||
|
||||
const ensureToolBlock = (input: {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
calledBy?: string
|
||||
parentToolCallId?: string
|
||||
spanId?: string
|
||||
parentSpanId?: string
|
||||
displayTitle?: string
|
||||
params?: Record<string, unknown>
|
||||
result?: { success: boolean; output?: unknown; error?: string }
|
||||
state?: string
|
||||
}): RawPersistedBlock => {
|
||||
const existingIndex = toolIndexById.get(input.toolCallId)
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = blocks[existingIndex]
|
||||
const existingToolCall = asPayloadRecord(existing.toolCall)
|
||||
existing.toolCall = {
|
||||
...(existingToolCall ?? {}),
|
||||
id: input.toolCallId,
|
||||
name: input.toolName,
|
||||
state:
|
||||
input.state ??
|
||||
(typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'),
|
||||
...(input.calledBy ? { calledBy: input.calledBy } : {}),
|
||||
...(input.params ? { params: input.params } : {}),
|
||||
...(input.result ? { result: input.result } : {}),
|
||||
...(input.displayTitle
|
||||
? {
|
||||
display: {
|
||||
title: input.displayTitle,
|
||||
},
|
||||
}
|
||||
: existingToolCall?.display
|
||||
? { display: existingToolCall.display }
|
||||
: {}),
|
||||
}
|
||||
if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId
|
||||
if (input.spanId) existing.spanId = input.spanId
|
||||
if (input.parentSpanId) existing.parentSpanId = input.parentSpanId
|
||||
return existing
|
||||
}
|
||||
|
||||
const nextBlock: RawPersistedBlock = {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
toolCall: {
|
||||
id: input.toolCallId,
|
||||
name: input.toolName,
|
||||
state: input.state ?? 'executing',
|
||||
...(input.calledBy ? { calledBy: input.calledBy } : {}),
|
||||
...(input.params ? { params: input.params } : {}),
|
||||
...(input.result ? { result: input.result } : {}),
|
||||
...(input.displayTitle
|
||||
? {
|
||||
display: {
|
||||
title: input.displayTitle,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(input.parentToolCallId ? { parentToolCallId: input.parentToolCallId } : {}),
|
||||
...(input.spanId ? { spanId: input.spanId } : {}),
|
||||
...(input.parentSpanId ? { parentSpanId: input.parentSpanId } : {}),
|
||||
}
|
||||
toolIndexById.set(input.toolCallId, blocks.length)
|
||||
blocks.push(nextBlock)
|
||||
return nextBlock
|
||||
}
|
||||
|
||||
for (const entry of events) {
|
||||
const parsed = entry.event
|
||||
lastTimestamp = parsed.ts
|
||||
if (typeof parsed.trace?.requestId === 'string') {
|
||||
requestId = parsed.trace.requestId
|
||||
}
|
||||
const scopedParentToolCallId =
|
||||
typeof parsed.scope?.parentToolCallId === 'string' ? parsed.scope.parentToolCallId : undefined
|
||||
const scopedAgentId =
|
||||
typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined
|
||||
const scopedSpanId = typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined
|
||||
const scopedParentSpanId =
|
||||
typeof parsed.scope?.parentSpanId === 'string' ? parsed.scope.parentSpanId : undefined
|
||||
const scopedSubagent = resolveScopedSubagent(
|
||||
scopedAgentId,
|
||||
scopedParentToolCallId,
|
||||
scopedSpanId
|
||||
)
|
||||
const spanIdentity: { spanId?: string; parentSpanId?: string } = {
|
||||
...(scopedSpanId ? { spanId: scopedSpanId } : {}),
|
||||
...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}),
|
||||
}
|
||||
|
||||
switch (parsed.type) {
|
||||
case MothershipStreamV1EventType.session: {
|
||||
if (parsed.payload.kind === MothershipStreamV1SessionKind.chat) {
|
||||
continue
|
||||
}
|
||||
if (parsed.payload.kind === MothershipStreamV1SessionKind.start) {
|
||||
continue
|
||||
}
|
||||
if (parsed.payload.kind === MothershipStreamV1SessionKind.trace) {
|
||||
requestId = parsed.payload.requestId
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.text: {
|
||||
const chunk = parsed.payload.text
|
||||
if (!chunk) {
|
||||
continue
|
||||
}
|
||||
const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main'
|
||||
const needsBoundaryNewline =
|
||||
lastContentSource !== null &&
|
||||
lastContentSource !== contentSource &&
|
||||
runningText.length > 0 &&
|
||||
!runningText.endsWith('\n')
|
||||
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
|
||||
const parentForBlock = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
appendTextBlock(blocks, normalizedChunk, {
|
||||
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
...spanIdentity,
|
||||
})
|
||||
runningText += normalizedChunk
|
||||
lastContentSource = contentSource
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.tool: {
|
||||
const payload = parsed.payload
|
||||
const toolCallId = payload.toolCallId
|
||||
|
||||
if ('previewPhase' in payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.phase === MothershipStreamV1ToolPhase.args_delta) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parentForBlock = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
|
||||
if (payload.phase === MothershipStreamV1ToolPhase.result) {
|
||||
ensureToolBlock({
|
||||
toolCallId,
|
||||
toolName: payload.toolName,
|
||||
calledBy: scopedSubagent,
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
...spanIdentity,
|
||||
state: resolveStreamToolOutcome(payload),
|
||||
result: {
|
||||
success: payload.success,
|
||||
...(payload.output !== undefined ? { output: payload.output } : {}),
|
||||
...(typeof payload.error === 'string' ? { error: payload.error } : {}),
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
ensureToolBlock({
|
||||
toolCallId,
|
||||
toolName: payload.toolName,
|
||||
calledBy: scopedSubagent,
|
||||
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
|
||||
...spanIdentity,
|
||||
displayTitle: getToolDisplayTitle(
|
||||
payload.toolName,
|
||||
isRecordLike(payload.arguments) ? payload.arguments : undefined
|
||||
),
|
||||
params: isRecordLike(payload.arguments) ? payload.arguments : undefined,
|
||||
state: typeof payload.status === 'string' ? payload.status : 'executing',
|
||||
})
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.span: {
|
||||
if (parsed.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) {
|
||||
continue
|
||||
}
|
||||
|
||||
const spanData = asPayloadRecord(parsed.payload.data)
|
||||
const parentToolCallIdFromData =
|
||||
typeof spanData?.tool_call_id === 'string'
|
||||
? spanData.tool_call_id
|
||||
: typeof spanData?.toolCallId === 'string'
|
||||
? spanData.toolCallId
|
||||
: undefined
|
||||
const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData
|
||||
const name = typeof parsed.payload.agent === 'string' ? parsed.payload.agent : scopedAgentId
|
||||
if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
|
||||
if (scopedSpanId) {
|
||||
subagentBySpanId.set(scopedSpanId, name)
|
||||
}
|
||||
if (parentToolCallId) {
|
||||
subagentByParentToolCallId.set(parentToolCallId, name)
|
||||
}
|
||||
activeSubagent = name
|
||||
activeSubagentParentToolCallId = parentToolCallId
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
|
||||
content: name,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
...spanIdentity,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.end) {
|
||||
if (spanData?.pending === true) {
|
||||
continue
|
||||
}
|
||||
if (scopedSpanId) {
|
||||
subagentBySpanId.delete(scopedSpanId)
|
||||
}
|
||||
if (parentToolCallId) {
|
||||
subagentByParentToolCallId.delete(parentToolCallId)
|
||||
}
|
||||
// Clear the legacy pointer only for THIS lane (by parent tool call id)
|
||||
// or an unscoped end — never by agent name, which would tear down a
|
||||
// concurrent same-name sibling that is still open.
|
||||
if (!parentToolCallId || parentToolCallId === activeSubagentParentToolCallId) {
|
||||
activeSubagent = undefined
|
||||
activeSubagentParentToolCallId = undefined
|
||||
}
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.end,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
...spanIdentity,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.run: {
|
||||
if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_start) {
|
||||
activeCompactionId = `compaction_${entry.eventId}`
|
||||
ensureToolBlock({
|
||||
toolCallId: activeCompactionId,
|
||||
toolName: 'context_compaction',
|
||||
displayTitle: 'Compacting context...',
|
||||
state: 'executing',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_done) {
|
||||
const compactionId = activeCompactionId ?? `compaction_${entry.eventId}`
|
||||
activeCompactionId = undefined
|
||||
ensureToolBlock({
|
||||
toolCallId: compactionId,
|
||||
toolName: 'context_compaction',
|
||||
displayTitle: 'Compacted context',
|
||||
state: MothershipStreamV1ToolOutcome.success,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.error: {
|
||||
const tag = buildInlineErrorTag(parsed.payload)
|
||||
if (runningText.includes(tag)) {
|
||||
continue
|
||||
}
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
const content = `${prefix}${tag}`
|
||||
const errorParent = resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId)
|
||||
appendTextBlock(blocks, content, {
|
||||
...(scopedSubagent ? { lane: 'subagent' as const } : {}),
|
||||
...(errorParent ? { parentToolCallId: errorParent } : {}),
|
||||
...spanIdentity,
|
||||
})
|
||||
runningText += content
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.complete: {
|
||||
if (parsed.payload.status === MothershipStreamV1CompletionStatus.cancelled) {
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
status: parsed.payload.status,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MothershipStreamV1EventType.resource: {
|
||||
continue
|
||||
}
|
||||
default: {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.length === 0 && !runningText && isTerminalStreamStatus(status)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeMessage({
|
||||
id: getLiveAssistantMessageId(streamId),
|
||||
role: 'assistant',
|
||||
content: runningText,
|
||||
timestamp: lastTimestamp ?? new Date().toISOString(),
|
||||
...(requestId ? { requestId } : {}),
|
||||
...(blocks.length > 0 ? { contentBlocks: blocks } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildEffectiveChatTranscript({
|
||||
messages,
|
||||
activeStreamId,
|
||||
streamSnapshot,
|
||||
}: BuildEffectiveChatTranscriptParams): PersistedMessage[] {
|
||||
if (!activeStreamId || !streamSnapshot) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const trailingMessage = messages[messages.length - 1]
|
||||
if (
|
||||
!trailingMessage ||
|
||||
trailingMessage.role !== 'user' ||
|
||||
trailingMessage.id !== activeStreamId
|
||||
) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const liveAssistant = buildLiveAssistantMessage({
|
||||
streamId: activeStreamId,
|
||||
events: streamSnapshot.events,
|
||||
status: streamSnapshot.status,
|
||||
})
|
||||
if (!liveAssistant) {
|
||||
return messages
|
||||
}
|
||||
|
||||
return [...messages, liveAssistant]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { mockAuthorizeWorkflow, mockGetActiveWorkflow } = vi.hoisted(() => ({
|
||||
mockAuthorizeWorkflow: vi.fn(),
|
||||
mockGetActiveWorkflow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workflow', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
|
||||
getActiveWorkflowRecord: mockGetActiveWorkflow,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
assertActiveWorkspaceAccess: vi.fn(),
|
||||
checkWorkspaceAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
getAccessibleCopilotChat,
|
||||
getAccessibleCopilotChatWithMessages,
|
||||
resolveOrCreateChat,
|
||||
} from '@/lib/copilot/chat/lifecycle'
|
||||
|
||||
const CHAT_ID = 'chat-1'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
// A chat with no workflow/workspace skips the authz lookups and authorizes directly.
|
||||
const chatRow = {
|
||||
id: CHAT_ID,
|
||||
userId: USER_ID,
|
||||
workflowId: null,
|
||||
workspaceId: null,
|
||||
type: 'copilot',
|
||||
title: 'Test',
|
||||
conversationId: null,
|
||||
resources: [],
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
const userMsg = { id: 'm-user', role: 'user', content: 'Hi', timestamp: '2026-01-01T00:00:00.000Z' }
|
||||
const asstMsg = {
|
||||
id: 'm-asst',
|
||||
role: 'assistant',
|
||||
content: 'Hello',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
}
|
||||
|
||||
describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('getAccessibleCopilotChatWithMessages sources messages from copilot_messages in seq order', async () => {
|
||||
// 1st query: chat metadata (select().from().where().limit())
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
|
||||
// 2nd query: messages (select().from().where().orderBy())
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }])
|
||||
|
||||
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.messages).toEqual([userMsg, asstMsg])
|
||||
expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('strips tool-result output on read, keeping success/error', async () => {
|
||||
const toolMsg = {
|
||||
id: 'm-tool',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tc-1',
|
||||
name: 'get_workflow_logs',
|
||||
state: 'success',
|
||||
result: { success: true, output: { huge: 'x'.repeat(5000) } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: toolMsg }])
|
||||
|
||||
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result?.messages?.[0].contentBlocks?.[0].toolCall?.result).toEqual({ success: true })
|
||||
expect(JSON.stringify(result?.messages)).not.toContain('huge')
|
||||
})
|
||||
|
||||
it('returns an empty transcript for a chat with no messages', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([])
|
||||
|
||||
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result?.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null and does NOT query messages when the chat is not found', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null and does NOT query messages when the row is found but authorization fails', async () => {
|
||||
// Row exists but belongs to a workflow the user cannot read.
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, workflowId: 'wf-1' }])
|
||||
mockAuthorizeWorkflow.mockResolvedValueOnce({ allowed: false, workflow: null })
|
||||
|
||||
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ ...chatRow, model: 'm', planArtifact: null, config: null },
|
||||
])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }])
|
||||
|
||||
const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID)
|
||||
|
||||
expect(result?.messages).toEqual([userMsg])
|
||||
})
|
||||
|
||||
it('resolveOrCreateChat returns conversationHistory from the table for an existing chat', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }])
|
||||
|
||||
const result = await resolveOrCreateChat({ chatId: CHAT_ID, userId: USER_ID, model: 'm' })
|
||||
|
||||
expect(result.isNew).toBe(false)
|
||||
expect(result.conversationHistory).toEqual([userMsg, asstMsg])
|
||||
})
|
||||
|
||||
it('resolveOrCreateChat creates a new chat with an empty transcript', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([chatRow])
|
||||
|
||||
const result = await resolveOrCreateChat({ userId: USER_ID, model: 'm' })
|
||||
|
||||
expect(result.isNew).toBe(true)
|
||||
expect(result.conversationHistory).toEqual([])
|
||||
expect(result.chat?.messages).toEqual([])
|
||||
const insertValues = dbChainMockFns.values.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(Object.hasOwn(insertValues, 'messages')).toBe(false)
|
||||
// a brand-new chat must not trigger a messages read
|
||||
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats, copilotMessages } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
getActiveWorkflowRecord,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm'
|
||||
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
assertActiveWorkspaceAccess,
|
||||
checkWorkspaceAccess,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatLifecycle')
|
||||
|
||||
export interface ChatLoadResult {
|
||||
chatId: string
|
||||
chat: CopilotChatDetailRow | null
|
||||
conversationHistory: unknown[]
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal column set needed to perform workflow/workspace authorization for a
|
||||
* copilot chat. Heavy TOAST-able columns (messages, planArtifact, previewYaml,
|
||||
* config, resources) are intentionally excluded — callers that only need to
|
||||
* verify ownership should not pay the detoast cost for those fields.
|
||||
*/
|
||||
const copilotChatAuthColumns = {
|
||||
id: copilotChats.id,
|
||||
userId: copilotChats.userId,
|
||||
workflowId: copilotChats.workflowId,
|
||||
workspaceId: copilotChats.workspaceId,
|
||||
type: copilotChats.type,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Column set for chat-detail callers that need chat metadata. The conversation
|
||||
* transcript is no longer selected from `copilot_chats.messages` (JSONB) —
|
||||
* reads now source it from the normalized `copilot_messages` table via
|
||||
* `loadCopilotChatMessages`, which avoids detoasting the large messages blob on
|
||||
* every load. The copilot-only TOAST-able fields (`previewYaml`,
|
||||
* `planArtifact`, `config`) and unused metadata (`model`, `pinned`,
|
||||
* `lastSeenAt`) remain excluded.
|
||||
*/
|
||||
const copilotChatDetailColumns = {
|
||||
...copilotChatAuthColumns,
|
||||
title: copilotChats.title,
|
||||
conversationId: copilotChats.conversationId,
|
||||
resources: copilotChats.resources,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Column set for the legacy copilot chat detail endpoint. Extends
|
||||
* `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the
|
||||
* fields the legacy `transformChat` response shape includes. Still drops
|
||||
* `previewYaml` (JSONB), `pinned`, and `lastSeenAt`.
|
||||
*/
|
||||
const copilotChatLegacyDetailColumns = {
|
||||
...copilotChatDetailColumns,
|
||||
model: copilotChats.model,
|
||||
planArtifact: copilotChats.planArtifact,
|
||||
config: copilotChats.config,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Load a chat's transcript from the normalized `copilot_messages` table in
|
||||
* canonical order (`seq` first, then `created_at`/`id` as a deterministic
|
||||
* tiebreak; `NULLS LAST` so any not-yet-sequenced row sorts after sequenced
|
||||
* ones). Each row's `content` is the full message object — identical in shape
|
||||
* to a legacy JSONB array element — so the downstream normalize/transcript
|
||||
* pipeline is unchanged.
|
||||
*/
|
||||
export async function loadCopilotChatMessages(chatId: string): Promise<PersistedMessage[]> {
|
||||
const rows = await db
|
||||
.select({ content: copilotMessages.content })
|
||||
.from(copilotMessages)
|
||||
.where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt)))
|
||||
.orderBy(
|
||||
sql`${copilotMessages.seq} asc nulls last`,
|
||||
asc(copilotMessages.createdAt),
|
||||
asc(copilotMessages.id)
|
||||
)
|
||||
// Also strip on read: rows written before the backfill still carry outputs.
|
||||
return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage))
|
||||
}
|
||||
|
||||
type CopilotChatAuthRow = Pick<
|
||||
typeof copilotChats.$inferSelect,
|
||||
'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type'
|
||||
>
|
||||
|
||||
export type CopilotChatDetailRow = Pick<
|
||||
typeof copilotChats.$inferSelect,
|
||||
| 'id'
|
||||
| 'userId'
|
||||
| 'workflowId'
|
||||
| 'workspaceId'
|
||||
| 'type'
|
||||
| 'title'
|
||||
| 'conversationId'
|
||||
| 'resources'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> & {
|
||||
/** Transcript assembled from `copilot_messages` (no longer a chat-row column). */
|
||||
messages: unknown[]
|
||||
}
|
||||
|
||||
export type CopilotChatLegacyDetailRow = CopilotChatDetailRow &
|
||||
Pick<typeof copilotChats.$inferSelect, 'model' | 'planArtifact' | 'config'>
|
||||
|
||||
async function authorizeCopilotChatRow<T extends CopilotChatAuthRow>(
|
||||
chat: T | undefined,
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<T | null> {
|
||||
if (!chat) {
|
||||
logger.warn('Copilot chat not found or not owned by user', { chatId, userId })
|
||||
return null
|
||||
}
|
||||
|
||||
if (chat.workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: chat.workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed || !authorization.workflow) {
|
||||
logger.warn('Copilot chat workflow not authorized for user', {
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: chat.workflowId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
} else if (chat.workspaceId) {
|
||||
const access = await checkWorkspaceAccess(chat.workspaceId, userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
logger.warn('Copilot chat workspace not accessible to user', {
|
||||
chatId,
|
||||
userId,
|
||||
workspaceId: chat.workspaceId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a copilot chat exists, is owned by the user, and the user has access
|
||||
* to its workflow/workspace. Selects only the columns required for the
|
||||
* authorization check — use this for routes that only need ownership
|
||||
* verification before a mutation (rename, delete, update-messages).
|
||||
*/
|
||||
export async function getAccessibleCopilotChatAuth(
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<CopilotChatAuthRow | null> {
|
||||
const [chat] = await db
|
||||
.select(copilotChatAuthColumns)
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
return authorizeCopilotChatRow(chat, chatId, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a copilot chat row for the legacy chat detail endpoint, including the
|
||||
* transcript plus `model`, `planArtifact`, and `config`. Drops `previewYaml`
|
||||
* (JSONB), `pinned`, and `lastSeenAt` — none of which the endpoint returns.
|
||||
*/
|
||||
export async function getAccessibleCopilotChat(
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<CopilotChatLegacyDetailRow | null> {
|
||||
const [chat] = await db
|
||||
.select(copilotChatLegacyDetailColumns)
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
|
||||
if (!authorized) return null
|
||||
|
||||
const messages = await loadCopilotChatMessages(chatId)
|
||||
return { ...authorized, messages }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a copilot chat with the conversation transcript and resources after
|
||||
* authorization, omitting copilot-only TOAST-able fields (`previewYaml`,
|
||||
* `planArtifact`, `config`) and unused metadata (`model`, `pinned`,
|
||||
* `lastSeenAt`). Use this for the mothership chat detail endpoint and the
|
||||
* shared `resolveOrCreateChat` path — every column read here is consumed
|
||||
* downstream, and dropping the others avoids per-request detoast overhead.
|
||||
*/
|
||||
export async function getAccessibleCopilotChatWithMessages(
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<CopilotChatDetailRow | null> {
|
||||
const [chat] = await db
|
||||
.select(copilotChatDetailColumns)
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
|
||||
if (!authorized) return null
|
||||
|
||||
const messages = await loadCopilotChatMessages(chatId)
|
||||
return { ...authorized, messages }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or create a copilot chat session.
|
||||
* If chatId is provided, loads the existing chat. Otherwise creates a new one.
|
||||
* Supports both workflow-scoped and workspace-scoped chats.
|
||||
*/
|
||||
export async function resolveOrCreateChat(params: {
|
||||
chatId?: string
|
||||
userId: string
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
model: string
|
||||
type?: 'mothership' | 'copilot'
|
||||
}): Promise<ChatLoadResult> {
|
||||
const { chatId, userId, workflowId, workspaceId, model, type } = params
|
||||
|
||||
if (workspaceId) {
|
||||
await assertActiveWorkspaceAccess(workspaceId, userId)
|
||||
}
|
||||
|
||||
if (chatId) {
|
||||
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
|
||||
|
||||
if (chat) {
|
||||
if (workflowId && chat.workflowId !== workflowId) {
|
||||
logger.warn('Copilot chat workflow mismatch', {
|
||||
chatId,
|
||||
userId,
|
||||
requestWorkflowId: workflowId,
|
||||
chatWorkflowId: chat.workflowId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
|
||||
if (workspaceId && chat.workspaceId !== workspaceId) {
|
||||
logger.warn('Copilot chat workspace mismatch', {
|
||||
chatId,
|
||||
userId,
|
||||
requestWorkspaceId: workspaceId,
|
||||
chatWorkspaceId: chat.workspaceId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
|
||||
if (chat.workflowId) {
|
||||
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
|
||||
if (!activeWorkflow) {
|
||||
logger.warn('Copilot chat workflow no longer active', {
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: chat.workflowId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chatId,
|
||||
chat: chat ?? null,
|
||||
conversationHistory: chat && Array.isArray(chat.messages) ? chat.messages : [],
|
||||
isNew: false,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const [newChat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
type: type ?? 'copilot',
|
||||
title: null,
|
||||
model,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.returning(copilotChatDetailColumns)
|
||||
|
||||
if (!newChat) {
|
||||
logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId })
|
||||
return {
|
||||
chatId: '',
|
||||
chat: null,
|
||||
conversationHistory: [],
|
||||
isNew: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chatId: newChat.id,
|
||||
chat: { ...newChat, messages: [] },
|
||||
conversationHistory: [],
|
||||
isNew: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { MothershipChat } from '@/lib/api/contracts/mothership-chats'
|
||||
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
|
||||
|
||||
/**
|
||||
* Lists a user's mothership (home) chats for a workspace as the contract wire
|
||||
* shape, shared by the `GET /api/mothership/chats` route and the workspace
|
||||
* sidebar prefetch. Performs no auth or workspace-access checks — callers
|
||||
* enforce access before invoking. Reconciles stale live-stream markers and
|
||||
* normalizes timestamps to ISO strings to honor the wire contract.
|
||||
*/
|
||||
export async function listMothershipChats(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<MothershipChat[]> {
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
title: copilotChats.title,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
activeStreamId: copilotChats.conversationId,
|
||||
lastSeenAt: copilotChats.lastSeenAt,
|
||||
pinned: copilotChats.pinned,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.userId, userId),
|
||||
eq(copilotChats.workspaceId, workspaceId),
|
||||
eq(copilotChats.type, 'mothership')
|
||||
)
|
||||
)
|
||||
.orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt))
|
||||
|
||||
const streamMarkers = await reconcileChatStreamMarkers(
|
||||
chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })),
|
||||
{ repairVerifiedStaleMarkers: true }
|
||||
)
|
||||
|
||||
return chats.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
activeStreamId: streamMarkers.get(c.id)?.streamId ?? null,
|
||||
lastSeenAt: c.lastSeenAt ? c.lastSeenAt.toISOString() : null,
|
||||
pinned: c.pinned,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
import {
|
||||
appendCopilotChatMessages,
|
||||
replaceCopilotChatMessages,
|
||||
} from '@/lib/copilot/chat/messages-store'
|
||||
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
|
||||
const userMsg: PersistedMessage = {
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const assistantMsg: PersistedMessage = {
|
||||
id: 'msg-asst-1',
|
||||
role: 'assistant',
|
||||
content: 'Hi back',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
}
|
||||
|
||||
const toolMsg: PersistedMessage = {
|
||||
id: 'msg-tool-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tc-1',
|
||||
name: 'get_workflow_logs',
|
||||
state: 'error',
|
||||
params: { workflowId: 'wf-1' },
|
||||
result: { success: false, output: { huge: 'x'.repeat(5000) }, error: 'too big' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** The persisted `content` of the most recently inserted row at `index`. */
|
||||
function lastRowContent(index: number): PersistedMessage {
|
||||
return lastValuesRows()[index].content as PersistedMessage
|
||||
}
|
||||
|
||||
/** The first arg passed to the most recent `.values(...)` call. */
|
||||
function lastValuesRows() {
|
||||
const calls = dbChainMockFns.values.mock.calls
|
||||
return calls[calls.length - 1][0] as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
describe('messages-store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
describe('appendCopilotChatMessages', () => {
|
||||
it('is a no-op on empty array', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [])
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inserts rows built from PersistedMessage shape', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [userMsg, assistantMsg])
|
||||
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledTimes(1)
|
||||
const rows = lastValuesRows()
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
chatId: 'chat-1',
|
||||
messageId: 'msg-user-1',
|
||||
role: 'user',
|
||||
content: userMsg,
|
||||
model: null,
|
||||
streamId: null,
|
||||
})
|
||||
expect(rows[0].createdAt as Date).toEqual(new Date(userMsg.timestamp))
|
||||
expect(rows[0].updatedAt as Date).toEqual(new Date(userMsg.timestamp))
|
||||
|
||||
expect(rows[1]).toMatchObject({
|
||||
chatId: 'chat-1',
|
||||
messageId: 'msg-asst-1',
|
||||
role: 'assistant',
|
||||
content: assistantMsg,
|
||||
})
|
||||
expect(rows[1].createdAt as Date).toEqual(new Date(assistantMsg.timestamp))
|
||||
})
|
||||
|
||||
it('assigns seq as 0-based array index when the chat has no prior rows', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([{ maxSeq: null }])
|
||||
|
||||
await appendCopilotChatMessages('chat-1', [userMsg, assistantMsg])
|
||||
const rows = lastValuesRows()
|
||||
expect(rows[0].seq).toBe(0)
|
||||
expect(rows[1].seq).toBe(1)
|
||||
})
|
||||
|
||||
it('continues seq from MAX(seq)+1 when the chat already has rows', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([{ maxSeq: 4 }])
|
||||
|
||||
await appendCopilotChatMessages('chat-1', [userMsg, assistantMsg])
|
||||
const rows = lastValuesRows()
|
||||
expect(rows[0].seq).toBe(5)
|
||||
expect(rows[1].seq).toBe(6)
|
||||
})
|
||||
|
||||
it('passes chatModel and streamId options to every row', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [userMsg, assistantMsg], {
|
||||
chatModel: 'claude-sonnet-4-5',
|
||||
streamId: 'stream-xyz',
|
||||
})
|
||||
|
||||
const rows = lastValuesRows()
|
||||
expect(rows[0].model).toBe('claude-sonnet-4-5')
|
||||
expect(rows[0].streamId).toBe('stream-xyz')
|
||||
expect(rows[1].model).toBe('claude-sonnet-4-5')
|
||||
expect(rows[1].streamId).toBe('stream-xyz')
|
||||
})
|
||||
|
||||
it('uses ON CONFLICT DO UPDATE that PRESERVES existing seq', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [userMsg])
|
||||
|
||||
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledTimes(1)
|
||||
const conflictArg = dbChainMockFns.onConflictDoUpdate.mock.calls[0][0]
|
||||
expect(conflictArg.target).toHaveLength(2)
|
||||
expect(conflictArg.set).toHaveProperty('content')
|
||||
expect(conflictArg.set).toHaveProperty('role')
|
||||
expect(conflictArg.set).toHaveProperty('model')
|
||||
expect(conflictArg.set).toHaveProperty('streamId')
|
||||
expect(conflictArg.set).toHaveProperty('updatedAt')
|
||||
expect(conflictArg.set.seq.strings.join('')).toContain('COALESCE(')
|
||||
})
|
||||
|
||||
it('collapses duplicate message ids to a single row', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [userMsg, { ...userMsg, content: 'dupe' }])
|
||||
const rows = lastValuesRows()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].messageId).toBe('msg-user-1')
|
||||
})
|
||||
|
||||
it('propagates DB errors — copilot_messages is the sole store', async () => {
|
||||
dbChainMockFns.onConflictDoUpdate.mockRejectedValueOnce(new Error('connection lost'))
|
||||
|
||||
await expect(appendCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow(
|
||||
'connection lost'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips tool-result output before persisting, keeping success/error', async () => {
|
||||
await appendCopilotChatMessages('chat-1', [toolMsg])
|
||||
|
||||
const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall
|
||||
expect(toolCall?.result).toEqual({ success: false, error: 'too big' })
|
||||
expect(JSON.stringify(lastValuesRows())).not.toContain('huge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceCopilotChatMessages', () => {
|
||||
it('deletes all chat rows when given an empty snapshot', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [])
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes only rows whose message_id is not in the new snapshot, then upserts', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [userMsg, assistantMsg])
|
||||
|
||||
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1)
|
||||
|
||||
const rows = lastValuesRows()
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.map((r) => r.messageId)).toEqual(['msg-user-1', 'msg-asst-1'])
|
||||
|
||||
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledTimes(1)
|
||||
const conflictArg = dbChainMockFns.onConflictDoUpdate.mock.calls[0][0]
|
||||
expect(conflictArg.set).toHaveProperty('streamId')
|
||||
expect(conflictArg.set).toHaveProperty('model')
|
||||
})
|
||||
|
||||
it('assigns seq as the snapshot array index (0-based)', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [userMsg, assistantMsg])
|
||||
const rows = lastValuesRows()
|
||||
expect(rows[0].seq).toBe(0)
|
||||
expect(rows[1].seq).toBe(1)
|
||||
})
|
||||
|
||||
it('OVERWRITES seq on conflict so positions re-densify after a delete', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [userMsg])
|
||||
const conflictArg = dbChainMockFns.onConflictDoUpdate.mock.calls[0][0]
|
||||
expect(conflictArg.set.seq.strings.join('')).toBe('excluded.seq')
|
||||
})
|
||||
|
||||
it('collapses duplicate message ids to a single row', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [userMsg, { ...userMsg, content: 'dupe' }])
|
||||
const rows = lastValuesRows()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].seq).toBe(0)
|
||||
})
|
||||
|
||||
it('passes chatModel to every row in the snapshot', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [userMsg], {
|
||||
chatModel: 'gpt-4o-mini',
|
||||
})
|
||||
|
||||
const rows = lastValuesRows()
|
||||
expect(rows[0].model).toBe('gpt-4o-mini')
|
||||
})
|
||||
|
||||
it('propagates DB errors — the snapshot is authoritative', async () => {
|
||||
dbChainMockFns.transaction.mockRejectedValueOnce(new Error('tx aborted'))
|
||||
|
||||
await expect(replaceCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow('tx aborted')
|
||||
})
|
||||
|
||||
it('strips tool-result output before persisting, keeping success/error', async () => {
|
||||
await replaceCopilotChatMessages('chat-1', [toolMsg])
|
||||
|
||||
const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall
|
||||
expect(toolCall?.result).toEqual({ success: false, error: 'too big' })
|
||||
expect(JSON.stringify(lastValuesRows())).not.toContain('huge')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotMessages } from '@sim/db/schema'
|
||||
import { and, eq, notInArray, sql } from 'drizzle-orm'
|
||||
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
/**
|
||||
* Keep the first occurrence of each message id. A single `INSERT ... ON
|
||||
* CONFLICT` cannot touch the same conflict target twice, so a repeated id
|
||||
* would otherwise throw.
|
||||
*/
|
||||
function dedupeById(messages: PersistedMessage[]): PersistedMessage[] {
|
||||
const seen = new Set<string>()
|
||||
const out: PersistedMessage[] = []
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.id)) continue
|
||||
seen.add(m.id)
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toRow(
|
||||
chatId: string,
|
||||
message: PersistedMessage,
|
||||
seq: number,
|
||||
options?: { chatModel?: string | null; streamId?: string | null }
|
||||
): typeof copilotMessages.$inferInsert {
|
||||
const ts = new Date(message.timestamp)
|
||||
return {
|
||||
chatId,
|
||||
messageId: message.id,
|
||||
role: message.role,
|
||||
content: stripToolResultOutput(message),
|
||||
seq,
|
||||
model: options?.chatModel ?? null,
|
||||
streamId: options?.streamId ?? null,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append messages to the `copilot_messages` table — the sole store for chat
|
||||
* transcripts. Throws on failure (a swallowed write would lose messages).
|
||||
* Pass `executor` to enlist the write in an existing transaction.
|
||||
*
|
||||
* `seq` is `MAX(seq) + index`, computed in JS. The read-then-insert is
|
||||
* non-atomic, but per-chat appends are serialized by the pending-stream lock
|
||||
* and the `seq, created_at, id` read order breaks any residual tie.
|
||||
*/
|
||||
export async function appendCopilotChatMessages(
|
||||
chatId: string,
|
||||
messages: PersistedMessage[],
|
||||
options?: { chatModel?: string | null; streamId?: string | null },
|
||||
executor: DbOrTx = db
|
||||
): Promise<void> {
|
||||
if (messages.length === 0) return
|
||||
const deduped = dedupeById(messages)
|
||||
const [maxRow] = await executor
|
||||
.select({ maxSeq: sql<number | null>`max(${copilotMessages.seq})` })
|
||||
.from(copilotMessages)
|
||||
.where(eq(copilotMessages.chatId, chatId))
|
||||
const base = (maxRow?.maxSeq ?? -1) + 1
|
||||
await executor
|
||||
.insert(copilotMessages)
|
||||
.values(deduped.map((m, i) => toRow(chatId, m, base + i, options)))
|
||||
.onConflictDoUpdate({
|
||||
target: [copilotMessages.chatId, copilotMessages.messageId],
|
||||
set: {
|
||||
content: sql`excluded.content`,
|
||||
role: sql`excluded.role`,
|
||||
model: sql`COALESCE(excluded.model, ${copilotMessages.model})`,
|
||||
streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`,
|
||||
seq: sql`COALESCE(${copilotMessages.seq}, excluded.seq)`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all messages for a chat from a full snapshot (used by update-messages).
|
||||
* Throws on failure. Pass `executor` to enlist the delete+insert in an existing
|
||||
* transaction; otherwise it runs in its own.
|
||||
*/
|
||||
export async function replaceCopilotChatMessages(
|
||||
chatId: string,
|
||||
messages: PersistedMessage[],
|
||||
options?: { chatModel?: string | null },
|
||||
executor?: DbOrTx
|
||||
): Promise<void> {
|
||||
const deduped = dedupeById(messages)
|
||||
const newMessageIds = deduped.map((m) => m.id)
|
||||
const run = async (tx: DbOrTx) => {
|
||||
await tx
|
||||
.delete(copilotMessages)
|
||||
.where(
|
||||
newMessageIds.length > 0
|
||||
? and(
|
||||
eq(copilotMessages.chatId, chatId),
|
||||
notInArray(copilotMessages.messageId, newMessageIds)
|
||||
)
|
||||
: eq(copilotMessages.chatId, chatId)
|
||||
)
|
||||
if (deduped.length === 0) return
|
||||
await tx
|
||||
.insert(copilotMessages)
|
||||
.values(deduped.map((m, i) => toRow(chatId, m, i, options)))
|
||||
.onConflictDoUpdate({
|
||||
target: [copilotMessages.chatId, copilotMessages.messageId],
|
||||
set: {
|
||||
content: sql`excluded.content`,
|
||||
role: sql`excluded.role`,
|
||||
model: sql`COALESCE(excluded.model, ${copilotMessages.model})`,
|
||||
streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`,
|
||||
seq: sql`excluded.seq`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
})
|
||||
}
|
||||
await (executor ? run(executor) : db.transaction(run))
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { envFlagsMock, workflowsUtilsMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({
|
||||
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
|
||||
mockGetHighestPrioritySubscription: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/plan-helpers', () => ({
|
||||
isPaid: vi.fn(
|
||||
(plan: string | null) => plan === 'pro' || plan === 'team' || plan === 'enterprise'
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
|
||||
|
||||
vi.mock('@/lib/mcp/utils', () => ({
|
||||
createMcpToolId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@/tools/registry', () => ({
|
||||
tools: {
|
||||
gmail_send: {
|
||||
id: 'gmail_send',
|
||||
name: 'Gmail Send',
|
||||
description: 'Send emails using Gmail',
|
||||
},
|
||||
brandfetch_search: {
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
},
|
||||
// Catalog marks run_workflow as client-routed / clientExecutable; registry ToolConfig has no routing fields.
|
||||
run_workflow: {
|
||||
id: 'run_workflow',
|
||||
name: 'Run Workflow',
|
||||
description: 'Run a workflow from the client',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/utils', () => ({
|
||||
getLatestVersionTools: vi.fn((input) => input),
|
||||
stripVersionSuffix: vi.fn((toolId: string) => toolId),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/block-visibility', () => ({
|
||||
getBlockVisibilityForCopilot: vi.fn(async () => ({
|
||||
revealed: new Set<string>(),
|
||||
disabled: new Set<string>(),
|
||||
previewTagged: new Set<string>(),
|
||||
})),
|
||||
visibilitySignature: vi.fn(() => 'vis:none'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/integration-tools', () => ({
|
||||
filterExposedIntegrationTools: vi.fn((tools: unknown[]) => tools),
|
||||
getExposedIntegrationTools: vi.fn(() => [
|
||||
{
|
||||
toolId: 'gmail_send',
|
||||
config: { id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail' },
|
||||
service: 'gmail',
|
||||
operation: 'send',
|
||||
},
|
||||
{
|
||||
toolId: 'brandfetch_search',
|
||||
config: {
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
},
|
||||
service: 'brandfetch',
|
||||
operation: 'search',
|
||||
},
|
||||
{
|
||||
toolId: 'run_workflow',
|
||||
config: {
|
||||
id: 'run_workflow',
|
||||
name: 'Run Workflow',
|
||||
description: 'Run a workflow from the client',
|
||||
},
|
||||
service: 'run',
|
||||
operation: 'workflow',
|
||||
},
|
||||
]),
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/params', () => ({
|
||||
createUserToolSchema: mockCreateUserToolSchema,
|
||||
}))
|
||||
|
||||
import {
|
||||
buildCopilotRequestPayload,
|
||||
buildIntegrationToolSchemas,
|
||||
clearIntegrationToolSchemaCacheForTests,
|
||||
} from './payload'
|
||||
|
||||
describe('buildIntegrationToolSchemas', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearIntegrationToolSchemaCacheForTests()
|
||||
mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
it('appends the email footer prompt for free users', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue(null)
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-free')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
|
||||
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-free')
|
||||
expect(gmailTool?.description).toContain('sent with sim ai')
|
||||
})
|
||||
|
||||
it('does not append the email footer prompt for paid users', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-paid')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
|
||||
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-paid')
|
||||
expect(gmailTool?.description).toBe('Send emails using Gmail')
|
||||
})
|
||||
|
||||
it('still builds integration tools when subscription lookup fails', async () => {
|
||||
mockGetHighestPrioritySubscription.mockRejectedValue(new Error('db unavailable'))
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-error')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
const brandfetchTool = toolSchemas.find((tool) => tool.name === 'brandfetch_search')
|
||||
|
||||
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-error')
|
||||
expect(gmailTool?.description).toBe('Send emails using Gmail')
|
||||
expect(brandfetchTool?.description).toBe('Search for brands by company name')
|
||||
})
|
||||
|
||||
it('emits executeLocally for dynamic client tools only', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-client')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
const runTool = toolSchemas.find((tool) => tool.name === 'run_workflow')
|
||||
|
||||
expect(gmailTool?.executeLocally).toBe(false)
|
||||
expect(runTool?.executeLocally).toBe(true)
|
||||
})
|
||||
|
||||
it('uses copilot-facing file schemas for integration tools', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
|
||||
|
||||
await buildIntegrationToolSchemas('user-copilot')
|
||||
|
||||
expect(mockCreateUserToolSchema).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'gmail_send' }),
|
||||
{ surface: 'copilot' }
|
||||
)
|
||||
expect(mockCreateUserToolSchema).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'brandfetch_search' }),
|
||||
{ surface: 'copilot' }
|
||||
)
|
||||
})
|
||||
|
||||
it('briefly reuses built schemas for the same user and surface', async () => {
|
||||
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
|
||||
|
||||
const first = await buildIntegrationToolSchemas('user-cache')
|
||||
first[0].input_schema.mutated = true
|
||||
const second = await buildIntegrationToolSchemas('user-cache')
|
||||
|
||||
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateUserToolSchema).toHaveBeenCalledTimes(3)
|
||||
expect(second[0].input_schema).not.toHaveProperty('mutated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCopilotRequestPayload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('passes workspaceContext through to the Go request payload', async () => {
|
||||
const payload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'debug workspace',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
workspaceContext: 'workspace inventory',
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'ws-1',
|
||||
workspaceContext: 'workspace inventory',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes user metadata through to the Go request payload', async () => {
|
||||
const payload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'what time is it',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
userTimezone: 'America/Los_Angeles',
|
||||
userMetadata: {
|
||||
name: 'Sid',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
userTimezone: 'America/Los_Angeles',
|
||||
userMetadata: {
|
||||
name: 'Sid',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes entitlements through and omits the field when empty', async () => {
|
||||
const withEntitlements = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'publish as a block',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
entitlements: ['custom-blocks'],
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] }))
|
||||
|
||||
const withoutEntitlements = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'publish as a block',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
entitlements: [],
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
expect(withoutEntitlements).not.toHaveProperty('entitlements')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,403 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { isPaid } from '@/lib/billing/plan-helpers'
|
||||
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
|
||||
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
|
||||
import {
|
||||
filterExposedIntegrationTools,
|
||||
getExposedIntegrationTools,
|
||||
} from '@/lib/copilot/integration-tools'
|
||||
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
|
||||
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
|
||||
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
|
||||
import { buildUserSkillTool } from '@/lib/mothership/skills'
|
||||
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { stripVersionSuffix } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatPayload')
|
||||
const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000
|
||||
const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES = 500
|
||||
|
||||
interface BuildPayloadParams {
|
||||
message: string
|
||||
workflowId?: string
|
||||
workflowName?: string
|
||||
workspaceId?: string
|
||||
userId: string
|
||||
userMessageId: string
|
||||
mode: string
|
||||
model: string
|
||||
provider?: string
|
||||
contexts?: Array<{ type: string; content: string; tag?: string; path?: string }>
|
||||
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
|
||||
commands?: string[]
|
||||
chatId?: string
|
||||
prefetch?: boolean
|
||||
implicitFeedback?: string
|
||||
workspaceContext?: string
|
||||
vfs?: VfsSnapshotV1
|
||||
userPermission?: string
|
||||
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
|
||||
entitlements?: string[]
|
||||
userTimezone?: string
|
||||
userMetadata?: {
|
||||
name?: string
|
||||
email?: string
|
||||
timezone?: string
|
||||
}
|
||||
includeMothershipTools?: boolean
|
||||
}
|
||||
|
||||
export interface ToolSchema {
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
defer_loading?: boolean
|
||||
executeLocally?: boolean
|
||||
params?: Record<string, unknown>
|
||||
/** Canonical integration service/folder (e.g. "slack"), for server-side grouping. */
|
||||
service?: string
|
||||
oauth?: { required: boolean; provider: string }
|
||||
}
|
||||
|
||||
interface BuildIntegrationToolSchemasOptions {
|
||||
schemaSurface?: 'default' | 'copilot'
|
||||
}
|
||||
|
||||
interface IntegrationToolSchemaCacheEntry {
|
||||
promise: Promise<ToolSchema[]>
|
||||
}
|
||||
|
||||
const integrationToolSchemaCache = new LRUCache<string, IntegrationToolSchemaCacheEntry>({
|
||||
max: INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES,
|
||||
ttl: INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS,
|
||||
})
|
||||
|
||||
function getIntegrationToolSchemaCacheKey(
|
||||
userId: string,
|
||||
workspaceId: string | undefined,
|
||||
schemaSurface: string,
|
||||
visSignature: string
|
||||
): string {
|
||||
// The visibility signature keys the entry to the viewer's gated projection —
|
||||
// two users in one workspace with different preview reveals must not share.
|
||||
return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature])
|
||||
}
|
||||
|
||||
function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] {
|
||||
return toolSchemas.map((tool) => {
|
||||
const cloned: ToolSchema = {
|
||||
...tool,
|
||||
input_schema: { ...tool.input_schema },
|
||||
}
|
||||
if (tool.params) cloned.params = { ...tool.params }
|
||||
if (tool.oauth) cloned.oauth = { ...tool.oauth }
|
||||
return cloned
|
||||
})
|
||||
}
|
||||
|
||||
export function clearIntegrationToolSchemaCacheForTests(): void {
|
||||
integrationToolSchemaCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build deferred integration tool schemas from the Sim tool registry.
|
||||
* Shared by the interactive chat payload builder and the non-interactive
|
||||
* block execution route so both paths send the same tool definitions to Go.
|
||||
*
|
||||
* When `workspaceId` is provided the user's workspace permission config is
|
||||
* loaded once and used to skip any tool whose owning block is not in the
|
||||
* workspace's `allowedIntegrations` allowlist.
|
||||
*/
|
||||
export async function buildIntegrationToolSchemas(
|
||||
userId: string,
|
||||
messageId?: string,
|
||||
options: BuildIntegrationToolSchemasOptions = { schemaSurface: 'copilot' },
|
||||
workspaceId?: string
|
||||
): Promise<ToolSchema[]> {
|
||||
const schemaSurface = options.schemaSurface ?? 'copilot'
|
||||
const vis = await getBlockVisibilityForCopilot(userId, workspaceId)
|
||||
const cacheKey = getIntegrationToolSchemaCacheKey(
|
||||
userId,
|
||||
workspaceId,
|
||||
schemaSurface,
|
||||
visibilitySignature(vis)
|
||||
)
|
||||
const cached = integrationToolSchemaCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cloneToolSchemas(await cached.promise)
|
||||
}
|
||||
|
||||
const promise = buildIntegrationToolSchemasUncached(
|
||||
userId,
|
||||
messageId,
|
||||
{ schemaSurface },
|
||||
workspaceId,
|
||||
vis
|
||||
).catch((error) => {
|
||||
integrationToolSchemaCache.delete(cacheKey)
|
||||
throw error
|
||||
})
|
||||
|
||||
integrationToolSchemaCache.set(cacheKey, {
|
||||
promise,
|
||||
})
|
||||
|
||||
return cloneToolSchemas(await promise)
|
||||
}
|
||||
|
||||
async function buildIntegrationToolSchemasUncached(
|
||||
userId: string,
|
||||
messageId: string | undefined,
|
||||
options: Required<BuildIntegrationToolSchemasOptions>,
|
||||
workspaceId?: string,
|
||||
vis: BlockVisibilityState | null = null
|
||||
): Promise<ToolSchema[]> {
|
||||
const reqLogger = logger.withMetadata({ messageId })
|
||||
const integrationTools: ToolSchema[] = []
|
||||
try {
|
||||
const { createUserToolSchema } = await import('@/tools/params')
|
||||
let shouldAppendEmailTagline = false
|
||||
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
shouldAppendEmailTagline = !subscription || !isPaid(subscription.plan)
|
||||
} catch (error) {
|
||||
reqLogger.warn('Failed to load subscription for copilot tool descriptions', {
|
||||
userId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
|
||||
let allowedIntegrations: Set<string> | null = null
|
||||
let toolIdToBlockType: Map<string, string> | null = null
|
||||
if (workspaceId) {
|
||||
try {
|
||||
const [{ getUserPermissionConfig }, { getAllBlocks }] = await Promise.all([
|
||||
import('@/ee/access-control/utils/permission-check'),
|
||||
import('@/blocks/registry'),
|
||||
])
|
||||
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (permissionConfig?.allowedIntegrations) {
|
||||
allowedIntegrations = new Set(
|
||||
permissionConfig.allowedIntegrations.map((i) => i.toLowerCase())
|
||||
)
|
||||
toolIdToBlockType = new Map()
|
||||
for (const blockConfig of getAllBlocks()) {
|
||||
const access = blockConfig.tools?.access
|
||||
if (!access) continue
|
||||
for (const toolId of access) {
|
||||
toolIdToBlockType.set(stripVersionSuffix(toolId), blockConfig.type.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reqLogger.warn('Failed to load permission config for tool schema filter', {
|
||||
userId,
|
||||
workspaceId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis)
|
||||
for (const { toolId, config: toolConfig, service } of exposedTools) {
|
||||
try {
|
||||
if (allowedIntegrations && toolIdToBlockType) {
|
||||
const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId))
|
||||
if (owningBlock && !allowedIntegrations.has(owningBlock)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const userSchema = createUserToolSchema(toolConfig, {
|
||||
surface: options.schemaSurface,
|
||||
})
|
||||
const catalogEntry = getToolEntry(toolId)
|
||||
integrationTools.push({
|
||||
name: toolId,
|
||||
service,
|
||||
description: getCopilotToolDescription(toolConfig, {
|
||||
isHosted,
|
||||
fallbackName: toolId,
|
||||
appendEmailTagline: shouldAppendEmailTagline,
|
||||
}),
|
||||
input_schema: { ...userSchema },
|
||||
defer_loading: true,
|
||||
executeLocally:
|
||||
catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client',
|
||||
...(toolConfig.oauth?.required && {
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: toolConfig.oauth.provider,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (toolError) {
|
||||
logger.warn(
|
||||
messageId
|
||||
? `Failed to build schema for tool, skipping [messageId:${messageId}]`
|
||||
: 'Failed to build schema for tool, skipping',
|
||||
{
|
||||
toolId,
|
||||
error: toError(toolError).message,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
messageId
|
||||
? `Failed to build tool schemas [messageId:${messageId}]`
|
||||
: 'Failed to build tool schemas',
|
||||
{
|
||||
error: toError(error).message,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return integrationTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request payload for the copilot backend.
|
||||
*/
|
||||
export async function buildCopilotRequestPayload(
|
||||
params: BuildPayloadParams,
|
||||
options: {
|
||||
selectedModel: string
|
||||
}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const {
|
||||
message,
|
||||
workflowId,
|
||||
userId,
|
||||
userMessageId,
|
||||
mode,
|
||||
provider,
|
||||
contexts,
|
||||
fileAttachments,
|
||||
commands,
|
||||
chatId,
|
||||
prefetch,
|
||||
implicitFeedback,
|
||||
} = params
|
||||
|
||||
const selectedModel = options.selectedModel
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
|
||||
|
||||
// Track uploaded files in the DB and build context tags instead of base64 inlining
|
||||
const uploadContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = []
|
||||
if (chatId && params.workspaceId && fileAttachments && fileAttachments.length > 0) {
|
||||
for (const f of fileAttachments) {
|
||||
const filename = (f.filename ?? f.name ?? 'file') as string
|
||||
const mediaType = (f.media_type ?? f.mimeType ?? 'application/octet-stream') as string
|
||||
try {
|
||||
const { displayName } = await trackChatUpload(
|
||||
params.workspaceId,
|
||||
userId,
|
||||
chatId,
|
||||
f.key,
|
||||
filename,
|
||||
mediaType,
|
||||
f.size
|
||||
)
|
||||
// Encode the read path per the percent-encoded VFS convention (matches
|
||||
// files/ and the uploads glob output). The materialize_file `fileName`
|
||||
// arg stays the raw display name — the upload resolver accepts both.
|
||||
let encodedUploadName = displayName
|
||||
try {
|
||||
encodedUploadName = encodeVfsSegment(displayName)
|
||||
} catch {
|
||||
encodedUploadName = displayName
|
||||
}
|
||||
const lines = [
|
||||
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
|
||||
`Read with: read("uploads/${encodedUploadName}")`,
|
||||
`To save permanently: materialize_file(fileName: "${displayName}")`,
|
||||
]
|
||||
if (displayName.endsWith('.json')) {
|
||||
lines.push(
|
||||
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
|
||||
)
|
||||
}
|
||||
uploadContexts.push({
|
||||
type: 'uploaded_file',
|
||||
content: lines.join('\n'),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Failed to track chat upload', {
|
||||
filename,
|
||||
chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allContexts = [...(contexts ?? []), ...uploadContexts]
|
||||
|
||||
let integrationTools: ToolSchema[] = []
|
||||
const mothershipTools: ToolSchema[] = []
|
||||
const payloadLogger = logger.withMetadata({ messageId: userMessageId })
|
||||
|
||||
if (effectiveMode === 'build') {
|
||||
integrationTools = await buildIntegrationToolSchemas(
|
||||
userId,
|
||||
userMessageId,
|
||||
{ schemaSurface: 'copilot' },
|
||||
params.workspaceId
|
||||
)
|
||||
|
||||
if (params.includeMothershipTools && params.workspaceId) {
|
||||
// Expose all workspace user-created skills via the single load_user_skill
|
||||
// tool. Available to every user; content is fetched sim-side when the
|
||||
// model calls it.
|
||||
try {
|
||||
const userSkillTool = await buildUserSkillTool(params.workspaceId)
|
||||
if (userSkillTool) mothershipTools.push(userSkillTool)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to build load_user_skill tool', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(params.workflowName ? { workflowName: params.workflowName } : {}),
|
||||
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
|
||||
userId,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
mode: transportMode,
|
||||
messageId: userMessageId,
|
||||
...(allContexts.length > 0 ? { context: allContexts } : {}),
|
||||
...(chatId ? { chatId } : {}),
|
||||
...(typeof prefetch === 'boolean' ? { prefetch } : {}),
|
||||
...(implicitFeedback ? { implicitFeedback } : {}),
|
||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
|
||||
...(commands && commands.length > 0 ? { commands } : {}),
|
||||
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
|
||||
...(params.vfs ? { vfs: params.vfs } : {}),
|
||||
...(params.userPermission ? { userPermission: params.userPermission } : {}),
|
||||
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),
|
||||
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
|
||||
...(params.userMetadata &&
|
||||
(params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone)
|
||||
? { userMetadata: params.userMetadata }
|
||||
: {}),
|
||||
// Tell the copilot file subagent which document toolchain to write. Emitted
|
||||
// only in Python mode so the JS path sends no new field (Go defaults to js).
|
||||
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
|
||||
isHosted,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/request/types'
|
||||
import {
|
||||
buildPersistedAssistantMessage,
|
||||
buildPersistedUserMessage,
|
||||
normalizeMessage,
|
||||
type PersistedMessage,
|
||||
stripToolResultOutput,
|
||||
} from './persisted-message'
|
||||
|
||||
describe('persisted-message', () => {
|
||||
it('round-trips canonical tool blocks through normalizeMessage', () => {
|
||||
const blockTimestamp = 1_700_000_000_000
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: 'done',
|
||||
requestId: 'req-1',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
timestamp: blockTimestamp,
|
||||
calledBy: 'workflow',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
status: 'success',
|
||||
displayTitle: 'Reading foo.txt',
|
||||
params: { path: 'foo.txt' },
|
||||
result: { success: true, output: { ok: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result)
|
||||
const normalized = normalizeMessage(persisted as unknown as Record<string, unknown>)
|
||||
|
||||
expect(normalized.contentBlocks).toEqual([
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
timestamp: blockTimestamp,
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
state: 'success',
|
||||
display: { title: 'Reading foo.txt' },
|
||||
params: { path: 'foo.txt' },
|
||||
result: { success: true, output: { ok: true } },
|
||||
calledBy: 'workflow',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
channel: 'assistant',
|
||||
content: 'done',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers an explicit persisted request ID override', () => {
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: 'done',
|
||||
requestId: 'go-trace-1',
|
||||
contentBlocks: [],
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result, 'sim-request-1')
|
||||
|
||||
expect(persisted.requestId).toBe('sim-request-1')
|
||||
})
|
||||
|
||||
it('redacts sim_key credential tags so persisted assistant messages never re-expose the key', () => {
|
||||
const live = `Here is your key: <credential>${JSON.stringify({ value: 'sk-sim-secret-123', type: 'sim_key' })}</credential> save it.`
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: live,
|
||||
requestId: 'req-1',
|
||||
contentBlocks: [{ type: 'text', content: live }],
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result)
|
||||
|
||||
expect(persisted.content).not.toContain('sk-sim-secret-123')
|
||||
expect(persisted.content).toContain('"redacted":true')
|
||||
const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text')
|
||||
expect(textBlock?.content).not.toContain('sk-sim-secret-123')
|
||||
expect(textBlock?.content).toContain('"redacted":true')
|
||||
})
|
||||
|
||||
it('redacts sim_key credential tags split across streamed text chunks', () => {
|
||||
const chunks = [
|
||||
'Here\'s your key:\n\n<credential>{"value": "sk-',
|
||||
'sim-secret',
|
||||
'-12345',
|
||||
'", "type":',
|
||||
' "sim_key"}</credential>',
|
||||
'\n\nDone.',
|
||||
]
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: chunks.join(''),
|
||||
requestId: 'req-1',
|
||||
contentBlocks: chunks.map((c) => ({ type: 'text', content: c })),
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result)
|
||||
|
||||
expect(persisted.content).not.toContain('sk-sim-secret-12345')
|
||||
expect(persisted.contentBlocks).toBeDefined()
|
||||
const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('')
|
||||
expect(joined).not.toContain('sk-sim-secret-12345')
|
||||
expect(joined).toContain('"redacted":true')
|
||||
})
|
||||
|
||||
it('redacts the api key from a persisted generate_api_key tool result output', () => {
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: '',
|
||||
requestId: 'req-1',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'generate_api_key',
|
||||
status: 'success',
|
||||
params: { name: 'workspace-key' },
|
||||
result: {
|
||||
success: true,
|
||||
output: {
|
||||
id: 'k1',
|
||||
name: 'workspace-key',
|
||||
key: 'sk-sim-tool-output-secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result)
|
||||
const toolBlock = persisted.contentBlocks?.find((b) => b.toolCall?.name === 'generate_api_key')
|
||||
const output = toolBlock?.toolCall?.result?.output as Record<string, unknown> | undefined
|
||||
|
||||
expect(output?.key).toBe('[REDACTED]')
|
||||
expect(output?.redacted).toBe(true)
|
||||
expect(JSON.stringify(persisted)).not.toContain('sk-sim-tool-output-secret')
|
||||
})
|
||||
|
||||
it('leaves non-sim_key credential tags untouched', () => {
|
||||
const live = `<credential>${JSON.stringify({ value: 'https://oauth.example/connect', type: 'link', provider: 'slack' })}</credential>`
|
||||
const result: OrchestratorResult = {
|
||||
success: true,
|
||||
content: live,
|
||||
requestId: 'req-1',
|
||||
contentBlocks: [{ type: 'text', content: live }],
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
const persisted = buildPersistedAssistantMessage(result)
|
||||
|
||||
expect(persisted.content).toContain('https://oauth.example/connect')
|
||||
})
|
||||
|
||||
it('normalizes legacy tool_call and top-level toolCalls shapes', () => {
|
||||
const normalized = normalizeMessage({
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
state: 'cancelled',
|
||||
display: { phaseLabel: 'Workspace' },
|
||||
},
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'glob',
|
||||
status: 'success',
|
||||
result: { matches: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(normalized.contentBlocks).toEqual([
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'read',
|
||||
state: 'cancelled',
|
||||
display: { title: 'Workspace' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
channel: 'assistant',
|
||||
content: 'hello',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('builds normalized user messages with stripped optional empties', () => {
|
||||
const msg = buildPersistedUserMessage({
|
||||
id: 'user-1',
|
||||
content: 'hello',
|
||||
fileAttachments: [],
|
||||
contexts: [],
|
||||
})
|
||||
|
||||
expect(msg).toMatchObject({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
})
|
||||
expect(msg.fileAttachments).toBeUndefined()
|
||||
expect(msg.contexts).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripToolResultOutput', () => {
|
||||
it('drops result.output but keeps success and error', () => {
|
||||
const message: PersistedMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'get_workflow_logs',
|
||||
state: 'error',
|
||||
params: { workflowId: 'wf-1' },
|
||||
display: { title: 'Reading logs' },
|
||||
result: { success: false, output: { huge: 'x'.repeat(1000) }, error: 'boom' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const stripped = stripToolResultOutput(message)
|
||||
|
||||
expect(stripped.contentBlocks?.[0].toolCall).toEqual({
|
||||
id: 'tool-1',
|
||||
name: 'get_workflow_logs',
|
||||
state: 'error',
|
||||
params: { workflowId: 'wf-1' },
|
||||
display: { title: 'Reading logs' },
|
||||
result: { success: false, error: 'boom' },
|
||||
})
|
||||
expect(message.contentBlocks?.[0].toolCall?.result).toHaveProperty('output')
|
||||
})
|
||||
|
||||
it('omits error when the original result had none', () => {
|
||||
const message: PersistedMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 't',
|
||||
name: 'read',
|
||||
state: 'success',
|
||||
result: { success: true, output: [1, 2, 3] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({
|
||||
success: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the same reference when there is nothing to strip', () => {
|
||||
const noBlocks: PersistedMessage = {
|
||||
id: 'u',
|
||||
role: 'user',
|
||||
content: 'hi',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
expect(stripToolResultOutput(noBlocks)).toBe(noBlocks)
|
||||
|
||||
const noOutput: PersistedMessage = {
|
||||
id: 'msg',
|
||||
role: 'assistant',
|
||||
content: 'done',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{ type: 'text', channel: 'assistant', content: 'done' },
|
||||
{ type: 'tool', phase: 'call', toolCall: { id: 't', name: 'read', state: 'pending' } },
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 't2',
|
||||
name: 'read',
|
||||
state: 'error',
|
||||
result: { success: false, error: 'x' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(stripToolResultOutput(noOutput)).toBe(noOutput)
|
||||
})
|
||||
|
||||
it('strips every tool block while leaving text/thinking blocks intact', () => {
|
||||
const message: PersistedMessage = {
|
||||
id: 'msg',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
contentBlocks: [
|
||||
{ type: 'text', channel: 'thinking', content: 'hmm' },
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'a',
|
||||
name: 'run_workflow',
|
||||
state: 'success',
|
||||
result: { success: true, output: { big: 1 } },
|
||||
},
|
||||
},
|
||||
{ type: 'text', channel: 'assistant', content: 'answer' },
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'b',
|
||||
name: 'read',
|
||||
state: 'success',
|
||||
result: { success: true, output: 'file contents' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const blocks = stripToolResultOutput(message).contentBlocks ?? []
|
||||
expect(blocks[0]).toEqual({ type: 'text', channel: 'thinking', content: 'hmm' })
|
||||
expect(blocks[1].toolCall?.result).toEqual({ success: true })
|
||||
expect(blocks[2]).toEqual({ type: 'text', channel: 'assistant', content: 'answer' })
|
||||
expect(blocks[3].toolCall?.result).toEqual({ success: true })
|
||||
expect(JSON.stringify(blocks)).not.toContain('file contents')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,656 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import {
|
||||
mergeAndRedactPersistedBlocks,
|
||||
redactSensitiveContent,
|
||||
redactToolCallResult,
|
||||
} from '@/lib/copilot/chat/sim-key-redaction'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1SpanLifecycleEvent,
|
||||
MothershipStreamV1SpanPayloadKind,
|
||||
type MothershipStreamV1StreamScope,
|
||||
MothershipStreamV1TextChannel,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
MothershipStreamV1ToolPhase,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LocalToolCallStatus,
|
||||
OrchestratorResult,
|
||||
} from '@/lib/copilot/request/types'
|
||||
|
||||
export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted'
|
||||
|
||||
interface PersistedToolCall {
|
||||
id: string
|
||||
name: string
|
||||
state: PersistedToolState
|
||||
params?: Record<string, unknown>
|
||||
result?: { success: boolean; output?: unknown; error?: string }
|
||||
error?: string
|
||||
calledBy?: string
|
||||
durationMs?: number
|
||||
display?: { title?: string }
|
||||
}
|
||||
|
||||
export interface PersistedContentBlock {
|
||||
type: MothershipStreamV1EventType
|
||||
lane?: MothershipStreamV1StreamScope['lane']
|
||||
channel?: MothershipStreamV1TextChannel
|
||||
phase?: MothershipStreamV1ToolPhase
|
||||
kind?: MothershipStreamV1SpanPayloadKind
|
||||
lifecycle?: MothershipStreamV1SpanLifecycleEvent
|
||||
status?: MothershipStreamV1CompletionStatus
|
||||
content?: string
|
||||
toolCall?: PersistedToolCall
|
||||
timestamp?: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
spanId?: string
|
||||
parentSpanId?: string
|
||||
}
|
||||
|
||||
export interface PersistedFileAttachment {
|
||||
id: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface PersistedMessageContext {
|
||||
kind: string
|
||||
label: string
|
||||
workflowId?: string
|
||||
knowledgeId?: string
|
||||
tableId?: string
|
||||
fileId?: string
|
||||
folderId?: string
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
export interface PersistedMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
requestId?: string
|
||||
contentBlocks?: PersistedContentBlock[]
|
||||
fileAttachments?: PersistedFileAttachment[]
|
||||
contexts?: PersistedMessageContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the `output` of every persisted tool result, keeping `success` and
|
||||
* `error`. Tool outputs are never rendered (the chat thread shows only the tool
|
||||
* name/title/status) and never replayed to the model (the upstream copilot
|
||||
* service owns conversation memory), so storing them only bloats
|
||||
* `copilot_messages.content` — a single `get_workflow_logs`/`run_workflow`
|
||||
* result can reach hundreds of MB and stall task loads.
|
||||
*
|
||||
* Applied on both the write path (so new rows never store outputs) and the read
|
||||
* path (so already-bloated rows still load fast). Returns the original
|
||||
* reference when there is nothing to strip, preserving memoized identity for
|
||||
* read-side consumers.
|
||||
*/
|
||||
export function stripToolResultOutput(message: PersistedMessage): PersistedMessage {
|
||||
if (!message.contentBlocks?.length) return message
|
||||
let changed = false
|
||||
const contentBlocks = message.contentBlocks.map((block) => {
|
||||
const toolCall = block.toolCall
|
||||
const result = toolCall?.result
|
||||
if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block
|
||||
changed = true
|
||||
const strippedResult: { success: boolean; error?: string } = { success: result.success }
|
||||
if (result.error !== undefined) strippedResult.error = result.error
|
||||
return { ...block, toolCall: { ...toolCall, result: strippedResult } }
|
||||
})
|
||||
return changed ? { ...message, contentBlocks } : message
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write: OrchestratorResult → PersistedMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveToolState(block: ContentBlock): PersistedToolState {
|
||||
const tc = block.toolCall
|
||||
if (!tc) return 'pending'
|
||||
if (tc.result?.success !== undefined) {
|
||||
return tc.result.success
|
||||
? MothershipStreamV1ToolOutcome.success
|
||||
: MothershipStreamV1ToolOutcome.error
|
||||
}
|
||||
return tc.status as PersistedToolState
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `timestamp` / `endedAt` from a source object onto a target object.
|
||||
* Shared by every block mapper (persist, display, snapshot) so the timing
|
||||
* metadata that drives the `Thought for Ns` chip survives the full
|
||||
* persist → normalize → display round-trip — and one rule lives in one place.
|
||||
*/
|
||||
export function withBlockTiming<T>(target: T, src: { timestamp?: number; endedAt?: number }): T {
|
||||
const writable = target as { timestamp?: number; endedAt?: number }
|
||||
if (typeof src.timestamp === 'number') writable.timestamp = src.timestamp
|
||||
if (typeof src.endedAt === 'number') writable.endedAt = src.endedAt
|
||||
return target
|
||||
}
|
||||
|
||||
function withBlockParent<T>(target: T, src: { parentToolCallId?: string }): T {
|
||||
if (src.parentToolCallId) {
|
||||
;(target as { parentToolCallId?: string }).parentToolCallId = src.parentToolCallId
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry deterministic span identity (spanId / parentSpanId) across a block
|
||||
* mapping so the nesting tree survives the persist → normalize → display round
|
||||
* trip. Shared by both the write and read paths.
|
||||
*/
|
||||
function withBlockSpan<T>(target: T, src: { spanId?: string; parentSpanId?: string }): T {
|
||||
const writable = target as { spanId?: string; parentSpanId?: string }
|
||||
if (src.spanId) writable.spanId = src.spanId
|
||||
if (src.parentSpanId) writable.parentSpanId = src.parentSpanId
|
||||
return target
|
||||
}
|
||||
|
||||
function mapContentBlock(block: ContentBlock): PersistedContentBlock {
|
||||
const persisted = mapContentBlockBody(block)
|
||||
return withBlockSpan(withBlockParent(withBlockTiming(persisted, block), block), block)
|
||||
}
|
||||
|
||||
function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: block.content,
|
||||
}
|
||||
case 'thinking':
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.thinking,
|
||||
content: block.content,
|
||||
}
|
||||
case 'subagent':
|
||||
return {
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
|
||||
content: block.content,
|
||||
}
|
||||
case 'subagent_text':
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
lane: 'subagent',
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: block.content,
|
||||
}
|
||||
case 'subagent_thinking':
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
lane: 'subagent',
|
||||
channel: MothershipStreamV1TextChannel.thinking,
|
||||
content: block.content,
|
||||
}
|
||||
case 'tool_call': {
|
||||
if (!block.toolCall) {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
content: block.content,
|
||||
}
|
||||
}
|
||||
const state = resolveToolState(block)
|
||||
const isSubagentTool = !!block.calledBy
|
||||
const isNonTerminal =
|
||||
state === MothershipStreamV1ToolOutcome.cancelled ||
|
||||
state === 'pending' ||
|
||||
state === 'executing'
|
||||
|
||||
const redactedResult = redactToolCallResult(block.toolCall.name, block.toolCall.result)
|
||||
|
||||
const toolCall: PersistedToolCall = {
|
||||
id: block.toolCall.id,
|
||||
name: block.toolCall.name,
|
||||
state,
|
||||
...(isSubagentTool && isNonTerminal ? {} : { result: redactedResult }),
|
||||
...(isSubagentTool && isNonTerminal
|
||||
? {}
|
||||
: block.toolCall.params
|
||||
? { params: block.toolCall.params }
|
||||
: {}),
|
||||
...(block.calledBy ? { calledBy: block.calledBy } : {}),
|
||||
...(block.toolCall.displayTitle
|
||||
? {
|
||||
display: {
|
||||
title: block.toolCall.displayTitle,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
return {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
toolCall,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { type: MothershipStreamV1EventType.text, content: block.content }
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPersistedAssistantMessage(
|
||||
result: OrchestratorResult,
|
||||
requestId?: string
|
||||
): PersistedMessage {
|
||||
const message: PersistedMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: redactSensitiveContent(result.content),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (requestId || result.requestId) {
|
||||
message.requestId = requestId || result.requestId
|
||||
}
|
||||
|
||||
if (result.contentBlocks.length > 0) {
|
||||
message.contentBlocks = mergeAndRedactPersistedBlocks(result.contentBlocks.map(mapContentBlock))
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export function withStoppedContentBlock(message: PersistedMessage): PersistedMessage {
|
||||
const contentBlocks = message.contentBlocks ?? []
|
||||
const hasAssistantText = contentBlocks.some(
|
||||
(block) =>
|
||||
block.type === MothershipStreamV1EventType.text &&
|
||||
block.channel !== MothershipStreamV1TextChannel.thinking &&
|
||||
block.content?.trim()
|
||||
)
|
||||
if (
|
||||
contentBlocks.some(
|
||||
(block) =>
|
||||
block.type === MothershipStreamV1EventType.complete &&
|
||||
block.status === MothershipStreamV1CompletionStatus.cancelled
|
||||
)
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
return normalizeMessage({
|
||||
...message,
|
||||
contentBlocks: [
|
||||
...(hasAssistantText || !message.content.trim()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: message.content,
|
||||
},
|
||||
]),
|
||||
...contentBlocks,
|
||||
{
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export interface UserMessageParams {
|
||||
id: string
|
||||
content: string
|
||||
fileAttachments?: PersistedFileAttachment[]
|
||||
contexts?: PersistedMessageContext[]
|
||||
}
|
||||
|
||||
export function buildPersistedUserMessage(params: UserMessageParams): PersistedMessage {
|
||||
const message: PersistedMessage = {
|
||||
id: params.id,
|
||||
role: 'user',
|
||||
content: params.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (params.fileAttachments && params.fileAttachments.length > 0) {
|
||||
message.fileAttachments = params.fileAttachments
|
||||
}
|
||||
|
||||
if (params.contexts && params.contexts.length > 0) {
|
||||
message.contexts = params.contexts.map((c) => ({
|
||||
kind: c.kind,
|
||||
label: c.label,
|
||||
...(c.workflowId ? { workflowId: c.workflowId } : {}),
|
||||
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
|
||||
...(c.tableId ? { tableId: c.tableId } : {}),
|
||||
...(c.fileId ? { fileId: c.fileId } : {}),
|
||||
...(c.folderId ? { folderId: c.folderId } : {}),
|
||||
...(c.chatId ? { chatId: c.chatId } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read: raw JSONB → PersistedMessage
|
||||
// Handles both canonical (type: 'tool', 'text', 'span', 'complete') and
|
||||
// legacy (type: 'tool_call', 'thinking', 'subagent', 'stopped') blocks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANONICAL_BLOCK_TYPES: Set<string> = new Set(Object.values(MothershipStreamV1EventType))
|
||||
|
||||
interface RawBlock {
|
||||
type: string
|
||||
lane?: string
|
||||
content?: string
|
||||
/** Go persists text blocks with key "text" instead of "content" */
|
||||
text?: string
|
||||
channel?: string
|
||||
phase?: string
|
||||
kind?: string
|
||||
lifecycle?: string
|
||||
status?: string
|
||||
timestamp?: number
|
||||
endedAt?: number
|
||||
parentToolCallId?: string
|
||||
spanId?: string
|
||||
parentSpanId?: string
|
||||
toolCall?: {
|
||||
id?: string
|
||||
name?: string
|
||||
state?: string
|
||||
params?: Record<string, unknown>
|
||||
result?: { success: boolean; output?: unknown; error?: string }
|
||||
display?: { text?: string; title?: string; phaseLabel?: string }
|
||||
calledBy?: string
|
||||
durationMs?: number
|
||||
error?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface LegacyToolCall {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
params?: Record<string, unknown>
|
||||
result?: unknown
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
const OUTCOME_NORMALIZATION: Record<string, PersistedToolState> = {
|
||||
[MothershipStreamV1ToolOutcome.success]: MothershipStreamV1ToolOutcome.success,
|
||||
[MothershipStreamV1ToolOutcome.error]: MothershipStreamV1ToolOutcome.error,
|
||||
[MothershipStreamV1ToolOutcome.cancelled]: MothershipStreamV1ToolOutcome.cancelled,
|
||||
[MothershipStreamV1ToolOutcome.skipped]: MothershipStreamV1ToolOutcome.skipped,
|
||||
[MothershipStreamV1ToolOutcome.rejected]: MothershipStreamV1ToolOutcome.rejected,
|
||||
aborted: MothershipStreamV1ToolOutcome.cancelled,
|
||||
failed: MothershipStreamV1ToolOutcome.error,
|
||||
interrupted: 'interrupted',
|
||||
pending: 'pending',
|
||||
executing: 'executing',
|
||||
}
|
||||
|
||||
function normalizeToolState(state: string | undefined): PersistedToolState {
|
||||
if (!state) return 'pending'
|
||||
return OUTCOME_NORMALIZATION[state] ?? MothershipStreamV1ToolOutcome.error
|
||||
}
|
||||
|
||||
function isCanonicalBlock(block: RawBlock): boolean {
|
||||
return CANONICAL_BLOCK_TYPES.has(block.type)
|
||||
}
|
||||
|
||||
function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock {
|
||||
const result: PersistedContentBlock = {
|
||||
type: block.type as MothershipStreamV1EventType,
|
||||
}
|
||||
if (block.lane === 'subagent') {
|
||||
result.lane = block.lane
|
||||
}
|
||||
const blockContent = block.content ?? block.text
|
||||
if (blockContent !== undefined) result.content = blockContent
|
||||
if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel
|
||||
if (block.phase) result.phase = block.phase as MothershipStreamV1ToolPhase
|
||||
if (block.kind) result.kind = block.kind as MothershipStreamV1SpanPayloadKind
|
||||
if (block.lifecycle) result.lifecycle = block.lifecycle as MothershipStreamV1SpanLifecycleEvent
|
||||
if (block.status) result.status = block.status as MothershipStreamV1CompletionStatus
|
||||
if (block.parentToolCallId) result.parentToolCallId = block.parentToolCallId
|
||||
if (block.toolCall) {
|
||||
result.toolCall = {
|
||||
id: block.toolCall.id ?? '',
|
||||
name: block.toolCall.name ?? '',
|
||||
state: normalizeToolState(block.toolCall.state),
|
||||
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
|
||||
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
|
||||
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),
|
||||
...(block.toolCall.error ? { error: block.toolCall.error } : {}),
|
||||
...(block.toolCall.durationMs ? { durationMs: block.toolCall.durationMs } : {}),
|
||||
...(block.toolCall.display
|
||||
? {
|
||||
display: {
|
||||
title:
|
||||
block.toolCall.display.title ??
|
||||
block.toolCall.display.text ??
|
||||
block.toolCall.display.phaseLabel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock {
|
||||
if (block.type === 'tool_call' && block.toolCall) {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
toolCall: {
|
||||
id: block.toolCall.id ?? '',
|
||||
name: block.toolCall.name ?? '',
|
||||
state: normalizeToolState(block.toolCall.state),
|
||||
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
|
||||
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
|
||||
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),
|
||||
...(block.toolCall.display
|
||||
? {
|
||||
display: {
|
||||
title:
|
||||
block.toolCall.display.title ??
|
||||
block.toolCall.display.text ??
|
||||
block.toolCall.display.phaseLabel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'thinking') {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.thinking,
|
||||
content: block.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'subagent' || block.type === 'subagent_text') {
|
||||
if (block.type === 'subagent_text') {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
lane: 'subagent',
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: block.content,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
|
||||
content: block.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'subagent_thinking') {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
lane: 'subagent',
|
||||
channel: MothershipStreamV1TextChannel.thinking,
|
||||
content: block.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'subagent_end') {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.span,
|
||||
kind: MothershipStreamV1SpanPayloadKind.subagent,
|
||||
lifecycle: MothershipStreamV1SpanLifecycleEvent.end,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'stopped') {
|
||||
return {
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: block.content ?? block.text,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlock(block: RawBlock): PersistedContentBlock {
|
||||
const result = isCanonicalBlock(block)
|
||||
? normalizeCanonicalBlock(block)
|
||||
: normalizeLegacyBlock(block)
|
||||
if (typeof block.timestamp === 'number' && result.timestamp === undefined) {
|
||||
result.timestamp = block.timestamp
|
||||
}
|
||||
if (typeof block.endedAt === 'number' && result.endedAt === undefined) {
|
||||
result.endedAt = block.endedAt
|
||||
}
|
||||
if (block.parentToolCallId && result.parentToolCallId === undefined) {
|
||||
result.parentToolCallId = block.parentToolCallId
|
||||
}
|
||||
if (block.spanId && result.spanId === undefined) {
|
||||
result.spanId = block.spanId
|
||||
}
|
||||
if (block.parentSpanId && result.parentSpanId === undefined) {
|
||||
result.parentSpanId = block.parentSpanId
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeLegacyToolCall(tc: LegacyToolCall): PersistedContentBlock {
|
||||
const state = normalizeToolState(tc.status)
|
||||
return {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
toolCall: {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
state,
|
||||
...(tc.params ? { params: tc.params } : {}),
|
||||
...(tc.result != null
|
||||
? {
|
||||
result: {
|
||||
success: tc.status === MothershipStreamV1ToolOutcome.success,
|
||||
output: tc.result,
|
||||
...(tc.error ? { error: tc.error } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(tc.durationMs ? { durationMs: tc.durationMs } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function blocksContainTools(blocks: RawBlock[]): boolean {
|
||||
return blocks.some((b) => b.type === 'tool_call' || b.type === MothershipStreamV1EventType.tool)
|
||||
}
|
||||
|
||||
function normalizeBlocks(rawBlocks: RawBlock[], messageContent: string): PersistedContentBlock[] {
|
||||
const blocks = rawBlocks.map(normalizeBlock)
|
||||
const hasAssistantText = blocks.some(
|
||||
(b) =>
|
||||
b.type === MothershipStreamV1EventType.text &&
|
||||
b.channel !== MothershipStreamV1TextChannel.thinking &&
|
||||
b.content?.trim()
|
||||
)
|
||||
if (!hasAssistantText && messageContent.trim()) {
|
||||
blocks.push({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: messageContent,
|
||||
})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function normalizeMessage(raw: Record<string, unknown>): PersistedMessage {
|
||||
const msg: PersistedMessage = {
|
||||
id: (raw.id as string) ?? generateId(),
|
||||
role: (raw.role as 'user' | 'assistant') ?? 'assistant',
|
||||
content: (raw.content as string) ?? '',
|
||||
timestamp: (raw.timestamp as string) ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (raw.requestId && typeof raw.requestId === 'string') {
|
||||
msg.requestId = raw.requestId
|
||||
}
|
||||
|
||||
const rawBlocks = raw.contentBlocks as RawBlock[] | undefined
|
||||
const rawToolCalls = raw.toolCalls as LegacyToolCall[] | undefined
|
||||
const hasBlocks = Array.isArray(rawBlocks) && rawBlocks.length > 0
|
||||
const hasToolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0
|
||||
|
||||
if (hasBlocks) {
|
||||
msg.contentBlocks = normalizeBlocks(rawBlocks!, msg.content)
|
||||
const contentBlocksAlreadyContainTools = blocksContainTools(rawBlocks!)
|
||||
if (hasToolCalls && !contentBlocksAlreadyContainTools) {
|
||||
msg.contentBlocks.push(...rawToolCalls!.map(normalizeLegacyToolCall))
|
||||
}
|
||||
} else if (hasToolCalls) {
|
||||
msg.contentBlocks = rawToolCalls!.map(normalizeLegacyToolCall)
|
||||
if (msg.content.trim()) {
|
||||
msg.contentBlocks.push({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
content: msg.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rawAttachments = raw.fileAttachments as PersistedFileAttachment[] | undefined
|
||||
if (Array.isArray(rawAttachments) && rawAttachments.length > 0) {
|
||||
msg.fileAttachments = rawAttachments
|
||||
}
|
||||
|
||||
const rawContexts = raw.contexts as PersistedMessageContext[] | undefined
|
||||
if (Array.isArray(rawContexts) && rawContexts.length > 0) {
|
||||
msg.contexts = rawContexts.map((c) => ({
|
||||
kind: c.kind,
|
||||
label: c.label,
|
||||
...(c.workflowId ? { workflowId: c.workflowId } : {}),
|
||||
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
|
||||
...(c.tableId ? { tableId: c.tableId } : {}),
|
||||
...(c.fileId ? { fileId: c.fileId } : {}),
|
||||
...(c.folderId ? { folderId: c.folderId } : {}),
|
||||
...(c.chatId ? { chatId: c.chatId } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import {
|
||||
authMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser
|
||||
const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
|
||||
|
||||
const {
|
||||
getEffectiveDecryptedEnv,
|
||||
generateWorkspaceSnapshot,
|
||||
processContextsServer,
|
||||
resolveActiveResourceContext,
|
||||
buildCopilotRequestPayload,
|
||||
createSSEStream,
|
||||
acquirePendingChatStream,
|
||||
getPendingChatStreamId,
|
||||
releasePendingChatStream,
|
||||
resolveOrCreateChat,
|
||||
finalizeAssistantTurn,
|
||||
appendCopilotChatMessages,
|
||||
mockPublishStatusChanged,
|
||||
} = vi.hoisted(() => ({
|
||||
getEffectiveDecryptedEnv: vi.fn(),
|
||||
generateWorkspaceSnapshot: vi.fn(),
|
||||
processContextsServer: vi.fn(),
|
||||
resolveActiveResourceContext: vi.fn(),
|
||||
buildCopilotRequestPayload: vi.fn(),
|
||||
createSSEStream: vi.fn(),
|
||||
acquirePendingChatStream: vi.fn(),
|
||||
getPendingChatStreamId: vi.fn(),
|
||||
releasePendingChatStream: vi.fn(),
|
||||
resolveOrCreateChat: vi.fn(),
|
||||
finalizeAssistantTurn: vi.fn(),
|
||||
appendCopilotChatMessages: vi.fn(),
|
||||
mockPublishStatusChanged: vi.fn(),
|
||||
}))
|
||||
|
||||
const getSession = authMockFns.mockGetSession
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
getEffectiveDecryptedEnv,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/workspace-context', () => ({
|
||||
generateWorkspaceSnapshot,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/process-contents', () => ({
|
||||
processContextsServer,
|
||||
resolveActiveResourceContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/payload', () => ({
|
||||
buildCopilotRequestPayload,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/lifecycle/start', () => ({
|
||||
createSSEStream,
|
||||
SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/session', () => ({
|
||||
acquirePendingChatStream,
|
||||
getPendingChatStreamId,
|
||||
releasePendingChatStream,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
|
||||
resolveOrCreateChat,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/terminal-state', () => ({
|
||||
finalizeAssistantTurn,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/messages-store', () => ({
|
||||
appendCopilotChatMessages,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat-status', () => ({
|
||||
chatPubSub: {
|
||||
publishStatusChanged: mockPublishStatusChanged,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => {
|
||||
const update = vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
const select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
return {
|
||||
db: {
|
||||
update,
|
||||
select,
|
||||
transaction: async (cb: (tx: { update: typeof update; select: typeof select }) => unknown) =>
|
||||
cb({ update, select }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(() => ({})),
|
||||
eq: vi.fn(() => ({})),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
|
||||
}))
|
||||
|
||||
import { handleUnifiedChatPost } from './post'
|
||||
|
||||
describe('handleUnifiedChatPost', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
resolveWorkflowIdForUser.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
workflowName: 'Workflow One',
|
||||
})
|
||||
getUserEntityPermissions.mockResolvedValue('write')
|
||||
getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' })
|
||||
generateWorkspaceSnapshot.mockResolvedValue({
|
||||
markdown: 'workspace context',
|
||||
snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] },
|
||||
})
|
||||
processContextsServer.mockResolvedValue([])
|
||||
resolveActiveResourceContext.mockResolvedValue(null)
|
||||
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
|
||||
createSSEStream.mockReturnValue(new ReadableStream())
|
||||
acquirePendingChatStream.mockResolvedValue(true)
|
||||
getPendingChatStreamId.mockResolvedValue(null)
|
||||
releasePendingChatStream.mockResolvedValue(undefined)
|
||||
resolveOrCreateChat.mockResolvedValue({
|
||||
chatId: 'chat-1',
|
||||
chat: { id: 'chat-1' },
|
||||
conversationHistory: [],
|
||||
isNew: true,
|
||||
})
|
||||
finalizeAssistantTurn.mockResolvedValue({
|
||||
found: true,
|
||||
updated: true,
|
||||
appendedAssistant: true,
|
||||
workspaceId: 'ws-1',
|
||||
outcome: 'appended_assistant',
|
||||
})
|
||||
})
|
||||
|
||||
it('routes workflow-attached chat requests through the copilot backend path', async () => {
|
||||
const response = await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(generateWorkspaceSnapshot).toHaveBeenCalledWith('ws-1', 'user-1')
|
||||
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceContext: 'workspace context',
|
||||
// Regression guard: the branch must forward the typed snapshot, not drop it.
|
||||
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
|
||||
}),
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
expect(createSSEStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleModel: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
orchestrateOptions: expect.objectContaining({
|
||||
workflowId: 'wf-1',
|
||||
goRoute: '/api/copilot',
|
||||
executionContext: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
requestMode: 'agent',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('routes workspace chat requests through the mothership backend path', async () => {
|
||||
const response = await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'ws-1',
|
||||
workspaceContext: 'workspace context',
|
||||
// Regression guard: the branch must forward the typed snapshot, not drop it.
|
||||
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
|
||||
}),
|
||||
{ selectedModel: '' }
|
||||
)
|
||||
expect(createSSEStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleModel: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
orchestrateOptions: expect.objectContaining({
|
||||
workspaceId: 'ws-1',
|
||||
goRoute: '/api/mothership',
|
||||
executionContext: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
workflowId: '',
|
||||
workspaceId: 'ws-1',
|
||||
requestMode: 'agent',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts tagged skill contexts and forwards them to context resolution', async () => {
|
||||
const response = await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
contexts: [{ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(processContextsServer).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }),
|
||||
]),
|
||||
'user-1',
|
||||
'Hello',
|
||||
'ws-1',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('persists cancelled partial responses from the server lifecycle', async () => {
|
||||
await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
||||
const onComplete = streamArgs?.orchestrateOptions?.onComplete
|
||||
expect(onComplete).toBeTypeOf('function')
|
||||
|
||||
await onComplete({
|
||||
success: false,
|
||||
cancelled: true,
|
||||
content: 'partial answer',
|
||||
contentBlocks: [],
|
||||
toolCalls: [],
|
||||
chatId: 'chat-1',
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(finalizeAssistantTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: expect.any(String),
|
||||
streamMarkerPolicy: 'active-or-cleared',
|
||||
assistantMessage: expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'partial answer',
|
||||
contentBlocks: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'complete', status: 'cancelled' }),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('persists partial responses when the server lifecycle throws (onError)', async () => {
|
||||
await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
||||
const onError = streamArgs?.orchestrateOptions?.onError
|
||||
expect(onError).toBeTypeOf('function')
|
||||
|
||||
await onError(new Error('bedrock overloaded'), {
|
||||
success: false,
|
||||
cancelled: false,
|
||||
content: 'partial answer',
|
||||
contentBlocks: [],
|
||||
toolCalls: [],
|
||||
chatId: 'chat-1',
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(finalizeAssistantTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: expect.any(String),
|
||||
streamMarkerPolicy: 'active-or-cleared',
|
||||
assistantMessage: expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'partial answer',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the stream marker without an assistant message when nothing streamed before the throw', async () => {
|
||||
await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
||||
const onError = streamArgs?.orchestrateOptions?.onError
|
||||
expect(onError).toBeTypeOf('function')
|
||||
|
||||
await onError(new Error('immediate failure'), {
|
||||
success: false,
|
||||
cancelled: false,
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
toolCalls: [],
|
||||
chatId: 'chat-1',
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
const lastCall = finalizeAssistantTurn.mock.calls.at(-1)?.[0]
|
||||
expect(lastCall).toMatchObject({
|
||||
chatId: 'chat-1',
|
||||
streamMarkerPolicy: 'active-or-cleared',
|
||||
})
|
||||
expect(lastCall?.assistantMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('republishes completed status when cancelled lifecycle persistence already ran', async () => {
|
||||
await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
workspaceId: 'ws-1',
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
||||
const onComplete = streamArgs?.orchestrateOptions?.onComplete
|
||||
expect(onComplete).toBeTypeOf('function')
|
||||
|
||||
finalizeAssistantTurn.mockResolvedValueOnce({
|
||||
found: true,
|
||||
updated: false,
|
||||
appendedAssistant: false,
|
||||
workspaceId: 'ws-1',
|
||||
outcome: 'assistant_already_persisted',
|
||||
})
|
||||
|
||||
await onComplete({
|
||||
success: false,
|
||||
cancelled: true,
|
||||
content: 'partial answer',
|
||||
contentBlocks: [],
|
||||
toolCalls: [],
|
||||
chatId: 'chat-1',
|
||||
requestId: 'request-1',
|
||||
})
|
||||
|
||||
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
chatId: 'chat-1',
|
||||
type: 'completed',
|
||||
streamId: streamArgs?.streamId,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests that have neither workflow nor workspace attachment', async () => {
|
||||
const response = await handleUnifiedChatPost(
|
||||
new NextRequest('http://localhost/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: 'Hello',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: 'workspaceId is required when workflowId is not provided',
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
|
||||
/**
|
||||
* Overrides the global `@sim/db` mock: the logs-context tests below need
|
||||
* controllable row data, which the stable `dbChainMockFns.limit` provides.
|
||||
*/
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
import { processContextsServer } from './process-contents'
|
||||
|
||||
describe('processContextsServer - skill contexts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resolves a tagged skill to full content + encoded VFS path', async () => {
|
||||
getSkillById.mockResolvedValue({
|
||||
id: 'sk-1',
|
||||
name: 'My Skill — PostHog',
|
||||
description: 'desc',
|
||||
content: '# My Skill\n\nDo the thing.',
|
||||
})
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'skill', skillId: 'sk-1', label: 'My Skill — PostHog' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
expect(getSkillById).toHaveBeenCalledWith({ skillId: 'sk-1', workspaceId: 'ws-1' })
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'skill',
|
||||
tag: '@My Skill — PostHog',
|
||||
content: '# My Skill\n\nDo the thing.',
|
||||
path: 'agent/skills/My%20Skill%20%E2%80%94%20PostHog.json',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a skill that does not resolve (unknown or cross-workspace)', async () => {
|
||||
getSkillById.mockResolvedValue(null)
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'skill', skillId: 'missing', label: 'x' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a skill when no workspace is in scope', async () => {
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'skill', skillId: 'sk-1', label: 'x' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(getSkillById).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('processContextsServer - logs contexts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'log-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionId: 'exec-1',
|
||||
level: 'error',
|
||||
trigger: 'manual',
|
||||
startedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
endedAt: new Date('2026-01-01T00:00:01.000Z'),
|
||||
totalDurationMs: 1000,
|
||||
executionData: {
|
||||
traceSpans: [
|
||||
{
|
||||
id: 'span-1',
|
||||
blockId: 'block-1',
|
||||
name: 'Agent 1',
|
||||
type: 'agent',
|
||||
status: 'failed',
|
||||
duration: 500,
|
||||
input: { prompt: 'do the thing' },
|
||||
output: { error: '429 No active subscription' },
|
||||
},
|
||||
],
|
||||
},
|
||||
costTotal: '0.05',
|
||||
workflowName: 'My Flow',
|
||||
},
|
||||
])
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
workflow: { workspaceId: 'ws-1' },
|
||||
})
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].type).toBe('logs')
|
||||
expect(result[0].tag).toBe('@My Flow')
|
||||
|
||||
const summary = JSON.parse(result[0].content)
|
||||
expect(summary).toMatchObject({
|
||||
executionId: 'exec-1',
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My Flow',
|
||||
level: 'error',
|
||||
trigger: 'manual',
|
||||
totalDurationMs: 1000,
|
||||
cost: { total: 0.05 },
|
||||
overview: [
|
||||
{
|
||||
id: 'span-1',
|
||||
blockId: 'block-1',
|
||||
name: 'Agent 1',
|
||||
type: 'agent',
|
||||
status: 'failed',
|
||||
durationMs: 500,
|
||||
},
|
||||
],
|
||||
})
|
||||
const serialized = JSON.stringify(summary)
|
||||
expect(serialized).not.toContain('do the thing')
|
||||
expect(serialized).not.toContain('429 No active subscription')
|
||||
expect(summary.note).toContain('query_logs')
|
||||
expect(summary.note).toContain('exec-1')
|
||||
})
|
||||
|
||||
it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => {
|
||||
const traceSpans = Array.from({ length: 2000 }, (_, i) => ({
|
||||
id: `span-${i}`,
|
||||
blockId: `block-${i}`,
|
||||
name: `Block ${i}`,
|
||||
type: 'agent',
|
||||
status: 'success',
|
||||
duration: 10,
|
||||
}))
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'log-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionId: 'exec-1',
|
||||
level: 'error',
|
||||
trigger: 'manual',
|
||||
startedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
endedAt: null,
|
||||
totalDurationMs: null,
|
||||
executionData: { traceSpans },
|
||||
costTotal: null,
|
||||
workflowName: 'My Flow',
|
||||
},
|
||||
])
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
workflow: { workspaceId: 'ws-1' },
|
||||
})
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
const summary = JSON.parse(result[0].content)
|
||||
expect(summary.overview).toBeUndefined()
|
||||
expect(summary.executionId).toBe('exec-1')
|
||||
expect(summary.note).toContain('query_logs')
|
||||
})
|
||||
|
||||
it('drops a log context when the workflow is outside the current workspace', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'log-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-other',
|
||||
executionId: 'exec-1',
|
||||
level: 'error',
|
||||
trigger: 'manual',
|
||||
startedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
endedAt: null,
|
||||
totalDurationMs: null,
|
||||
costTotal: null,
|
||||
workflowName: 'My Flow',
|
||||
},
|
||||
])
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
workflow: { workspaceId: 'ws-other' },
|
||||
})
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a log context the user is not authorized to read', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'log-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionId: 'exec-1',
|
||||
level: 'error',
|
||||
trigger: 'manual',
|
||||
startedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
endedAt: null,
|
||||
totalDurationMs: null,
|
||||
costTotal: null,
|
||||
workflowName: 'My Flow',
|
||||
},
|
||||
])
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
})
|
||||
|
||||
const result = await processContextsServer(
|
||||
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
|
||||
'user-1',
|
||||
'hello',
|
||||
'ws-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,843 @@
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { knowledgeBase, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
getActiveWorkflowRecord,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm'
|
||||
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import {
|
||||
buildVfsFolderPathMap,
|
||||
canonicalBlockVfsPath,
|
||||
canonicalKnowledgeBaseVfsDir,
|
||||
canonicalTableVfsPath,
|
||||
canonicalWorkflowVfsDir,
|
||||
canonicalWorkspaceFilePath,
|
||||
encodeVfsPathSegments,
|
||||
encodeVfsSegment,
|
||||
} from '@/lib/copilot/vfs/path-utils'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
|
||||
import { toOverview } from '@/lib/logs/log-views'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { getTableById } from '@/lib/table/service'
|
||||
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getSkillById } from '@/lib/workflows/skills/operations'
|
||||
import { listFolders } from '@/lib/workflows/utils'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { escapeRegExp } from '@/executor/constants'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
type AgentContextType =
|
||||
| 'past_chat'
|
||||
| 'workflow'
|
||||
| 'current_workflow'
|
||||
| 'blocks'
|
||||
| 'logs'
|
||||
| 'knowledge'
|
||||
| 'table'
|
||||
| 'file'
|
||||
| 'workflow_block'
|
||||
| 'docs'
|
||||
| 'folder'
|
||||
| 'filefolder'
|
||||
| 'active_resource'
|
||||
| 'skill'
|
||||
|
||||
interface AgentContext {
|
||||
type: AgentContextType
|
||||
tag: string
|
||||
content: string
|
||||
/**
|
||||
* Canonical, URL-encoded VFS path for the tagged resource (e.g.
|
||||
* `agent/skills/My%20Skill.json`). Tagged resources are sent as path
|
||||
* pointers so the model reads them on demand via VFS tools instead of the
|
||||
* full body bloating the request. Skills are the exception: they carry both
|
||||
* `path` and the full `content` so the skill is autoloaded.
|
||||
*/
|
||||
path?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('ProcessContents')
|
||||
|
||||
// Server-side variant (recommended for use in API routes)
|
||||
export async function processContextsServer(
|
||||
contexts: ChatContext[] | undefined,
|
||||
userId: string,
|
||||
userMessage?: string,
|
||||
currentWorkspaceId?: string,
|
||||
chatId?: string
|
||||
): Promise<AgentContext[]> {
|
||||
if (!Array.isArray(contexts) || contexts.length === 0) return []
|
||||
const tasks = contexts.map(async (ctx) => {
|
||||
try {
|
||||
if (ctx.kind === 'skill' && ctx.skillId && currentWorkspaceId) {
|
||||
return await processSkillFromDb(
|
||||
ctx.skillId,
|
||||
currentWorkspaceId,
|
||||
ctx.label ? `@${ctx.label}` : '@'
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'past_chat' && ctx.chatId) {
|
||||
return await processPastChatFromDb(
|
||||
ctx.chatId,
|
||||
userId,
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
currentWorkspaceId
|
||||
)
|
||||
}
|
||||
if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) {
|
||||
return await processWorkflowFromDb(
|
||||
ctx.workflowId,
|
||||
userId,
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
ctx.kind,
|
||||
currentWorkspaceId,
|
||||
chatId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'knowledge' && ctx.knowledgeId) {
|
||||
return await processKnowledgeFromDb(
|
||||
ctx.knowledgeId,
|
||||
userId,
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
currentWorkspaceId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) {
|
||||
return await processBlockMetadata(
|
||||
ctx.blockIds[0],
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
userId,
|
||||
currentWorkspaceId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'logs' && ctx.executionId) {
|
||||
return await processExecutionLogFromDb(
|
||||
ctx.executionId,
|
||||
userId,
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
currentWorkspaceId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) {
|
||||
return await processWorkflowBlockFromDb(
|
||||
ctx.workflowId,
|
||||
userId,
|
||||
ctx.blockId,
|
||||
ctx.label,
|
||||
currentWorkspaceId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'table' && ctx.tableId && currentWorkspaceId) {
|
||||
const result = await resolveTableResource(ctx.tableId, currentWorkspaceId)
|
||||
if (!result) return null
|
||||
return {
|
||||
type: 'table',
|
||||
tag: ctx.label ? `@${ctx.label}` : '@',
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
}
|
||||
}
|
||||
if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) {
|
||||
const result = await resolveFileResource(ctx.fileId, currentWorkspaceId)
|
||||
if (!result) return null
|
||||
return {
|
||||
type: 'file',
|
||||
tag: ctx.label ? `@${ctx.label}` : '@',
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
}
|
||||
}
|
||||
if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) {
|
||||
const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId)
|
||||
if (!result) return null
|
||||
return {
|
||||
type: 'folder',
|
||||
tag: ctx.label ? `@${ctx.label}` : '@',
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
}
|
||||
}
|
||||
if (ctx.kind === 'filefolder' && ctx.fileFolderId && currentWorkspaceId) {
|
||||
const result = await resolveFileFolderResource(ctx.fileFolderId, currentWorkspaceId)
|
||||
if (!result) return null
|
||||
return {
|
||||
type: 'filefolder',
|
||||
tag: ctx.label ? `@${ctx.label}` : '@',
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
}
|
||||
}
|
||||
if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) {
|
||||
const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId)
|
||||
if (!result) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: ctx.label ? `@${ctx.label}` : '@',
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
}
|
||||
}
|
||||
if (ctx.kind === 'docs') {
|
||||
try {
|
||||
const { searchDocumentationServerTool } = await import(
|
||||
'@/lib/copilot/tools/server/docs/search-documentation'
|
||||
)
|
||||
const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation'
|
||||
const query = sanitizeMessageForDocs(rawQuery, contexts)
|
||||
const res = await searchDocumentationServerTool.execute({ query, topK: 10 })
|
||||
const content = JSON.stringify(res?.results || [])
|
||||
return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content }
|
||||
} catch (e) {
|
||||
logger.error('Failed to process docs context', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed processing context (server)', { ctx, error })
|
||||
return null
|
||||
}
|
||||
})
|
||||
const results = await Promise.all(tasks)
|
||||
const filtered = results.filter(
|
||||
(r): r is AgentContext =>
|
||||
!!r &&
|
||||
((typeof r.content === 'string' && r.content.trim().length > 0) ||
|
||||
(typeof r.path === 'string' && r.path.length > 0))
|
||||
)
|
||||
logger.info('Processed contexts (server)', {
|
||||
totalRequested: contexts.length,
|
||||
totalProcessed: filtered.length,
|
||||
kinds: Array.from(filtered.reduce((s, r) => s.add(r.type), new Set<string>())),
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string {
|
||||
if (!rawMessage) return ''
|
||||
if (!Array.isArray(contexts) || contexts.length === 0) {
|
||||
// No context mapping; conservatively strip all @mentions-like tokens
|
||||
const stripped = rawMessage
|
||||
.replace(/(^|\s)@([^\s]+)/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
return stripped
|
||||
}
|
||||
|
||||
// Gather labels by kind
|
||||
const blockLabels = new Set(
|
||||
contexts
|
||||
.filter((c) => c.kind === 'blocks')
|
||||
.map((c) => c.label)
|
||||
.filter((l): l is string => typeof l === 'string' && l.length > 0)
|
||||
)
|
||||
const nonBlockLabels = new Set(
|
||||
contexts
|
||||
.filter((c) => c.kind !== 'blocks')
|
||||
.map((c) => c.label)
|
||||
.filter((l): l is string => typeof l === 'string' && l.length > 0)
|
||||
)
|
||||
|
||||
let result = rawMessage
|
||||
|
||||
// 1) Remove all non-block mentions entirely
|
||||
for (const label of nonBlockLabels) {
|
||||
const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g')
|
||||
result = result.replace(pattern, ' ')
|
||||
}
|
||||
|
||||
// 2) For block mentions, strip the '@' but keep the block name
|
||||
for (const label of blockLabels) {
|
||||
const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g')
|
||||
result = result.replace(pattern, label)
|
||||
}
|
||||
|
||||
// 3) Remove any remaining @mentions (unknown or not in contexts)
|
||||
result = result.replace(/(^|\s)@([^\s]+)/g, ' ')
|
||||
|
||||
// Normalize whitespace
|
||||
result = result.replace(/\s{2,}/g, ' ').trim()
|
||||
return result
|
||||
}
|
||||
|
||||
async function processSkillFromDb(
|
||||
skillId: string,
|
||||
workspaceId: string,
|
||||
tag: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const s = await getSkillById({ skillId, workspaceId })
|
||||
if (!s) return null
|
||||
// Skills are autoloaded: carry the full SKILL.md body so the Go side can
|
||||
// inject it into the dynamic system message for the turn. The path lets the
|
||||
// model re-read the canonical VFS file if it needs to.
|
||||
const path = `agent/skills/${encodeVfsSegment(s.name)}.json`
|
||||
return { type: 'skill', tag, content: s.content, path }
|
||||
} catch (error) {
|
||||
logger.error('Error processing skill context (db)', { skillId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processPastChatFromDb(
|
||||
chatId: string,
|
||||
userId: string,
|
||||
tag: string,
|
||||
currentWorkspaceId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const { getAccessibleCopilotChatWithMessages } = await import('./lifecycle')
|
||||
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
|
||||
if (!chat) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentWorkspaceId) {
|
||||
if (chat.workspaceId && chat.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
if (chat.workflowId) {
|
||||
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
|
||||
if (!activeWorkflow || activeWorkflow.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
const messages = Array.isArray(chat.messages) ? (chat as any).messages : []
|
||||
const content = messages
|
||||
.map((m: any) => {
|
||||
const role = m.role || 'user'
|
||||
let text = ''
|
||||
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
|
||||
text = m.contentBlocks
|
||||
.filter((b: any) => b?.type === 'text')
|
||||
.map((b: any) => String(b.content || ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
if (!text && typeof m.content === 'string') text = m.content
|
||||
return `${role}: ${text}`.trim()
|
||||
})
|
||||
.filter((s: string) => s.length > 0)
|
||||
.join('\n')
|
||||
logger.info('Processed past_chat context from DB', {
|
||||
chatId,
|
||||
length: content.length,
|
||||
lines: content ? content.split('\n').length : 0,
|
||||
})
|
||||
return { type: 'past_chat', tag, content }
|
||||
} catch (error) {
|
||||
logger.error('Error processing past chat from db', { chatId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workflow folder id to its canonical, per-segment-encoded VFS folder
|
||||
* path. Returns null for root-level workflows or when the folder can't be
|
||||
* resolved. Uses the shared {@link buildVfsFolderPathMap} so the pointer path
|
||||
* matches what the workspace VFS serves.
|
||||
*/
|
||||
async function resolveWorkflowFolderPath(
|
||||
workspaceId: string | null | undefined,
|
||||
folderId: string | null | undefined
|
||||
): Promise<string | null> {
|
||||
if (!folderId || !workspaceId) return null
|
||||
try {
|
||||
const folders = await listFolders(workspaceId)
|
||||
return buildVfsFolderPathMap(folders).get(folderId) ?? null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve workflow folder path', { workspaceId, folderId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processWorkflowFromDb(
|
||||
workflowId: string,
|
||||
userId: string | undefined,
|
||||
tag: string,
|
||||
kind: 'workflow' | 'current_workflow' = 'workflow',
|
||||
currentWorkspaceId?: string,
|
||||
_chatId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
let workflowRecord: Awaited<ReturnType<typeof getActiveWorkflowRecord>> = null
|
||||
|
||||
if (userId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return null
|
||||
}
|
||||
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
workflowRecord = authorization.workflow ?? null
|
||||
}
|
||||
|
||||
if (!workflowRecord) {
|
||||
workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
}
|
||||
if (!workflowRecord) return null
|
||||
|
||||
// Emit a VFS-path pointer instead of the full (potentially huge) workflow
|
||||
// state/meta. `current_workflow` points at the live state; a plain
|
||||
// `workflow` mention points at the lighter metadata file.
|
||||
const folderPath = await resolveWorkflowFolderPath(
|
||||
workflowRecord.workspaceId ?? currentWorkspaceId,
|
||||
workflowRecord.folderId
|
||||
)
|
||||
const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath })
|
||||
const path = kind === 'current_workflow' ? `${dir}/state.json` : `${dir}/meta.json`
|
||||
return { type: kind, tag, content: '', path }
|
||||
} catch (error) {
|
||||
logger.error('Error processing workflow context', { workflowId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processPastChat(chatId: string, tagOverride?: string): Promise<AgentContext | null> {
|
||||
try {
|
||||
// boundary-raw-fetch: GET /api/mothership/chat?chatId=... has no defineRouteContract;
|
||||
// the route forwards to the copilot chat handler and emits a free-form chat envelope
|
||||
// that isn't covered by mothershipChatGetQuerySchema or copilotChatGetContract.
|
||||
const resp = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`)
|
||||
if (!resp.ok) {
|
||||
logger.error('Failed to fetch past chat', { chatId, status: resp.status })
|
||||
return null
|
||||
}
|
||||
const data = await resp.json()
|
||||
const messages = Array.isArray(data?.chat?.messages) ? data.chat.messages : []
|
||||
const content = messages
|
||||
.map((m: any) => {
|
||||
const role = m.role || 'user'
|
||||
// Prefer contentBlocks text if present (joins text blocks), else use content
|
||||
let text = ''
|
||||
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
|
||||
text = m.contentBlocks
|
||||
.filter((b: any) => b?.type === 'text')
|
||||
.map((b: any) => String(b.content || ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
if (!text && typeof m.content === 'string') text = m.content
|
||||
return `${role}: ${text}`.trim()
|
||||
})
|
||||
.filter((s: string) => s.length > 0)
|
||||
.join('\n')
|
||||
logger.info('Processed past_chat context via API', { chatId, length: content.length })
|
||||
|
||||
return { type: 'past_chat', tag: tagOverride || '@', content }
|
||||
} catch (error) {
|
||||
logger.error('Error processing past chat', { chatId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat alias; used by processContexts above
|
||||
async function processPastChatViaApi(chatId: string, tag?: string) {
|
||||
return processPastChat(chatId, tag)
|
||||
}
|
||||
|
||||
async function processKnowledgeFromDb(
|
||||
knowledgeBaseId: string,
|
||||
userId: string | undefined,
|
||||
tag: string,
|
||||
currentWorkspaceId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
if (userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return null
|
||||
}
|
||||
if (currentWorkspaceId && accessCheck.knowledgeBase?.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)]
|
||||
if (currentWorkspaceId) {
|
||||
conditions.push(eq(knowledgeBase.workspaceId, currentWorkspaceId))
|
||||
}
|
||||
const kbRows = await dbReplica
|
||||
.select({
|
||||
id: knowledgeBase.id,
|
||||
name: knowledgeBase.name,
|
||||
})
|
||||
.from(knowledgeBase)
|
||||
.where(and(...conditions))
|
||||
.limit(1)
|
||||
const kb = kbRows?.[0]
|
||||
if (!kb) return null
|
||||
|
||||
return {
|
||||
type: 'knowledge',
|
||||
tag,
|
||||
content: '',
|
||||
path: `${canonicalKnowledgeBaseVfsDir(kb.name)}/meta.json`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing knowledge context (db)', { knowledgeBaseId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processBlockMetadata(
|
||||
blockId: string,
|
||||
tag: string,
|
||||
userId?: string,
|
||||
workspaceId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const permissionConfig =
|
||||
userId && workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
|
||||
logger.debug('Block not allowed by integration allowlist', { blockId, userId })
|
||||
return null
|
||||
}
|
||||
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
if (!(blockRegistry as any)[blockId]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { type: 'blocks', tag, content: '', path: canonicalBlockVfsPath(blockId) }
|
||||
} catch (error) {
|
||||
logger.error('Error processing block metadata', { blockId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function processWorkflowBlockFromDb(
|
||||
workflowId: string,
|
||||
userId: string | undefined,
|
||||
blockId: string,
|
||||
label?: string,
|
||||
currentWorkspaceId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
let workflowRecord: Awaited<ReturnType<typeof getActiveWorkflowRecord>> = null
|
||||
if (userId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return null
|
||||
}
|
||||
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
workflowRecord = authorization.workflow ?? null
|
||||
}
|
||||
|
||||
if (!workflowRecord) {
|
||||
workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
}
|
||||
if (!workflowRecord) return null
|
||||
|
||||
const folderPath = await resolveWorkflowFolderPath(
|
||||
workflowRecord.workspaceId ?? currentWorkspaceId,
|
||||
workflowRecord.folderId
|
||||
)
|
||||
const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath })
|
||||
const tag = label ? `@${label} in Workflow` : `@${blockId} in Workflow`
|
||||
// Point at the workflow state; the block id tells the model which node to
|
||||
// look up inside state.json without inlining the full block definition.
|
||||
return {
|
||||
type: 'workflow_block',
|
||||
tag,
|
||||
content: `Block id: ${blockId}`,
|
||||
path: `${dir}/state.json`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing workflow_block context', { workflowId, blockId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap on the serialized summary (including the block overview tree) sent for
|
||||
* a tagged run. `toOverview` already excludes every block's input/output, so
|
||||
* this is a safety net against pathological span counts, not the primary
|
||||
* defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down
|
||||
* since this lands in the prompt unconditionally rather than behind an
|
||||
* explicit tool call.
|
||||
*/
|
||||
const MAX_LOG_SUMMARY_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* Resolve a tagged run to a compact summary instead of its full execution
|
||||
* trace. A run's trace can carry every block's input/output plus nested
|
||||
* tool-call spans, which is unbounded and would repeatedly blow the context
|
||||
* window if inlined directly. The summary includes the block-level overview
|
||||
* tree (name/type/status/timing/cost, no input or output — the same
|
||||
* projection `query_logs`'s `overview` view returns) so the model can see
|
||||
* which block failed without a round trip, and points it at `query_logs` for
|
||||
* that block's actual input/output/error, or to grep the trace.
|
||||
*
|
||||
* `materializeExecutionData` only unwraps a top-level object-storage pointer,
|
||||
* for runs whose whole trace was offloaded as one blob — a no-op for the
|
||||
* common inline case. Individual span input/output stay as large-value refs;
|
||||
* `toOverview` never resolves those.
|
||||
*/
|
||||
async function processExecutionLogFromDb(
|
||||
executionId: string,
|
||||
userId: string | undefined,
|
||||
tag: string,
|
||||
currentWorkspaceId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const { workflowExecutionLogs, workflow } = await import('@sim/db/schema')
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
costTotal: workflowExecutionLogs.costTotal,
|
||||
workflowName: workflow.name,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
const log = rows?.[0] as any
|
||||
if (!log) return null
|
||||
|
||||
if (userId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: log.workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return null
|
||||
}
|
||||
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
|
||||
const executionData = (await materializeExecutionData(
|
||||
log.executionData as Record<string, unknown> | null,
|
||||
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
|
||||
)) as { traceSpans?: TraceSpan[] } | undefined
|
||||
const overview = executionData?.traceSpans?.length
|
||||
? toOverview(executionData.traceSpans)
|
||||
: undefined
|
||||
|
||||
const summary = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
level: log.level,
|
||||
trigger: log.trigger,
|
||||
startedAt: log.startedAt?.toISOString?.() || String(log.startedAt),
|
||||
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
|
||||
totalDurationMs: log.totalDurationMs ?? null,
|
||||
workflowName: log.workflowName || '',
|
||||
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
|
||||
overview,
|
||||
note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`,
|
||||
}
|
||||
|
||||
if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) {
|
||||
summary.overview = undefined
|
||||
}
|
||||
|
||||
const content = JSON.stringify(summary)
|
||||
return { type: 'logs', tag, content }
|
||||
} catch (error) {
|
||||
logger.error('Error processing execution log context (db)', { executionId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active resource context resolution (direct DB lookups, workspace-scoped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the content of the currently active resource tab via direct DB
|
||||
* queries. Each resource type has a dedicated handler that fetches only the
|
||||
* single resource needed — avoiding the full VFS materialisation overhead.
|
||||
*/
|
||||
export async function resolveActiveResourceContext(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
chatId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
switch (resourceType) {
|
||||
case 'workflow': {
|
||||
const ctx = await processWorkflowFromDb(
|
||||
resourceId,
|
||||
userId,
|
||||
'@active_resource',
|
||||
'current_workflow',
|
||||
workspaceId,
|
||||
chatId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: ctx.content,
|
||||
path: ctx.path,
|
||||
}
|
||||
}
|
||||
case 'knowledgebase': {
|
||||
const ctx = await processKnowledgeFromDb(
|
||||
resourceId,
|
||||
userId,
|
||||
'@active_resource',
|
||||
workspaceId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: ctx.content,
|
||||
path: ctx.path,
|
||||
}
|
||||
}
|
||||
case 'table': {
|
||||
return await resolveTableResource(resourceId, workspaceId)
|
||||
}
|
||||
case 'file': {
|
||||
return await resolveFileResource(resourceId, workspaceId)
|
||||
}
|
||||
case 'folder': {
|
||||
return await resolveFolderResource(resourceId, workspaceId)
|
||||
}
|
||||
case 'filefolder': {
|
||||
return await resolveFileFolderResource(resourceId, workspaceId)
|
||||
}
|
||||
case 'scheduledtask': {
|
||||
return await resolveScheduledTaskResource(resourceId, workspaceId)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve active resource context', { resourceType, resourceId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
async function resolveTableResource(
|
||||
tableId: string,
|
||||
workspaceId: string
|
||||
): Promise<AgentContext | null> {
|
||||
const table = await getTableById(tableId)
|
||||
if (!table) return null
|
||||
if (table.workspaceId !== workspaceId) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: '',
|
||||
path: canonicalTableVfsPath(table.name),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveScheduledTaskResource(
|
||||
scheduleId: string,
|
||||
workspaceId: string
|
||||
): Promise<AgentContext | null> {
|
||||
const [row] = await db
|
||||
.select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSchedule.id, scheduleId),
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt),
|
||||
// Mirror the VFS materializer (workspace-vfs `materializeJobs`), which
|
||||
// excludes completed jobs — otherwise we'd point at a meta.json it never
|
||||
// wrote and the agent's read would dangle.
|
||||
ne(workflowSchedule.status, 'completed')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
// The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see
|
||||
// workspace-vfs `materializeJobs`); emit the same lightweight path pointer so
|
||||
// the agent reads it via the VFS instead of us inlining the (heavy) row.
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: '',
|
||||
path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFileResource(
|
||||
fileId: string,
|
||||
workspaceId: string
|
||||
): Promise<AgentContext | null> {
|
||||
const record = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!record) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: '',
|
||||
path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFileFolderResource(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const rawPath = await getWorkspaceFileFolderPath(workspaceId, folderId)
|
||||
if (!rawPath) return null
|
||||
const encoded = encodeVfsPathSegments(rawPath.split('/').filter(Boolean))
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: '',
|
||||
path: `files/${encoded}`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve file folder resource', { folderId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFolderResource(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<AgentContext | null> {
|
||||
const folderPath = await resolveWorkflowFolderPath(workspaceId, folderId)
|
||||
if (!folderPath) return null
|
||||
return {
|
||||
type: 'active_resource',
|
||||
tag: '@active_resource',
|
||||
content: '',
|
||||
path: `workflows/${folderPath}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import {
|
||||
captureRevealedSimKeys,
|
||||
extractRevealedSimKeys,
|
||||
restoreRevealedSimKeysForMessage,
|
||||
} from './sim-key-redaction'
|
||||
|
||||
const credential = (value: string) =>
|
||||
`<credential>${JSON.stringify({ value, type: 'sim_key' })}</credential>`
|
||||
const redacted = `<credential>${JSON.stringify({ type: 'sim_key', redacted: true })}</credential>`
|
||||
|
||||
describe('sim-key-redaction', () => {
|
||||
describe('extractRevealedSimKeys', () => {
|
||||
it('returns sim_key values in document order', () => {
|
||||
const text = `first ${credential('sk-sim-A')} mid ${credential('sk-sim-B')}`
|
||||
expect(extractRevealedSimKeys(text)).toEqual(['sk-sim-A', 'sk-sim-B'])
|
||||
})
|
||||
|
||||
it('skips redacted entries and non-sim_key tags', () => {
|
||||
const link = `<credential>${JSON.stringify({ value: 'https://x', type: 'link', provider: 'slack' })}</credential>`
|
||||
const text = `${link} ${credential('sk-sim-A')} ${redacted}`
|
||||
expect(extractRevealedSimKeys(text)).toEqual(['sk-sim-A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureRevealedSimKeys', () => {
|
||||
it('records new keys under each provided key', () => {
|
||||
const cache = new Map<string, string[]>()
|
||||
captureRevealedSimKeys(cache, ['msg-1', 'req-1'], credential('sk-sim-A'))
|
||||
expect(cache.get('msg-1')).toEqual(['sk-sim-A'])
|
||||
expect(cache.get('req-1')).toEqual(['sk-sim-A'])
|
||||
})
|
||||
|
||||
it('extends but never shrinks the captured list across calls', () => {
|
||||
const cache = new Map<string, string[]>()
|
||||
captureRevealedSimKeys(
|
||||
cache,
|
||||
['msg-1'],
|
||||
`${credential('sk-sim-A')} ${credential('sk-sim-B')}`
|
||||
)
|
||||
captureRevealedSimKeys(cache, ['msg-1'], credential('sk-sim-A'))
|
||||
expect(cache.get('msg-1')).toEqual(['sk-sim-A', 'sk-sim-B'])
|
||||
})
|
||||
|
||||
it('skips undefined keys without throwing', () => {
|
||||
const cache = new Map<string, string[]>()
|
||||
captureRevealedSimKeys(cache, ['msg-1', undefined], credential('sk-sim-A'))
|
||||
expect(cache.get('msg-1')).toEqual(['sk-sim-A'])
|
||||
expect(cache.size).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores content with no credential tag', () => {
|
||||
const cache = new Map<string, string[]>()
|
||||
captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text')
|
||||
expect(cache.has('msg-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreRevealedSimKeysForMessage', () => {
|
||||
it('substitutes the live key back into a redacted message', () => {
|
||||
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: `Here is your key: ${redacted} save it.`,
|
||||
contentBlocks: [{ type: 'text', content: `Here is your key: ${redacted} save it.` }],
|
||||
}
|
||||
const restored = restoreRevealedSimKeysForMessage(msg, cache)
|
||||
expect(restored.content).toContain('"sk-sim-A"')
|
||||
expect(restored.content).not.toContain('"redacted":true')
|
||||
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
|
||||
})
|
||||
|
||||
it('substitutes multiple keys in stream order', () => {
|
||||
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: `first ${redacted} second ${redacted}`,
|
||||
}
|
||||
const restored = restoreRevealedSimKeysForMessage(msg, cache)
|
||||
expect(restored.content).toBe(
|
||||
`first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a redacted tag in place if no live value is captured for that slot', () => {
|
||||
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: `first ${redacted} second ${redacted}`,
|
||||
}
|
||||
const restored = restoreRevealedSimKeysForMessage(msg, cache)
|
||||
expect(restored.content).toBe(`first ${credential('sk-sim-A')} second ${redacted}`)
|
||||
})
|
||||
|
||||
it('returns the same message reference when nothing to restore', () => {
|
||||
const cache = new Map<string, string[]>()
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'no credentials here',
|
||||
}
|
||||
expect(restoreRevealedSimKeysForMessage(msg, cache)).toBe(msg)
|
||||
})
|
||||
|
||||
it('does nothing for user messages', () => {
|
||||
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A']]])
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: redacted,
|
||||
}
|
||||
expect(restoreRevealedSimKeysForMessage(msg, cache)).toBe(msg)
|
||||
})
|
||||
|
||||
it('threads the cursor across separate content blocks so each block gets its matching key', () => {
|
||||
const cache = new Map<string, string[]>([['msg-1', ['sk-sim-A', 'sk-sim-B']]])
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: `first ${redacted} (tool ran) second ${redacted}`,
|
||||
contentBlocks: [
|
||||
{ type: 'text', content: `first ${redacted}` },
|
||||
{ type: 'tool_call', content: '' },
|
||||
{ type: 'text', content: `second ${redacted}` },
|
||||
],
|
||||
}
|
||||
const restored = restoreRevealedSimKeysForMessage(msg, cache)
|
||||
expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"')
|
||||
expect(restored.contentBlocks?.[0].content).not.toContain('"sk-sim-B"')
|
||||
expect(restored.contentBlocks?.[2].content).toContain('"sk-sim-B"')
|
||||
expect(restored.contentBlocks?.[2].content).not.toContain('"sk-sim-A"')
|
||||
})
|
||||
|
||||
it('isolates revealed values by message id (multiple keys across messages)', () => {
|
||||
const cache = new Map<string, string[]>([
|
||||
['msg-1', ['sk-sim-A']],
|
||||
['msg-2', ['sk-sim-B']],
|
||||
])
|
||||
const msg1: ChatMessage = { id: 'msg-1', role: 'assistant', content: redacted }
|
||||
const msg2: ChatMessage = { id: 'msg-2', role: 'assistant', content: redacted }
|
||||
expect(restoreRevealedSimKeysForMessage(msg1, cache).content).toContain('sk-sim-A')
|
||||
expect(restoreRevealedSimKeysForMessage(msg2, cache).content).toContain('sk-sim-B')
|
||||
expect(restoreRevealedSimKeysForMessage(msg1, cache).content).not.toContain('sk-sim-B')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
/**
|
||||
* Two-sided handling of `sim_key` API keys in the Mothership chat:
|
||||
*
|
||||
* - **Write side** (server, runs in `buildPersistedAssistantMessage`):
|
||||
* strip every revealed `<credential type="sim_key">` value before the row
|
||||
* hits Postgres. Reloading a chat days later — or pulling the row from the
|
||||
* DB directly — never re-exposes the key.
|
||||
*
|
||||
* - **Read side** (client, runs in `useChat`'s message selector): an in-memory
|
||||
* page-session cache captures revealed values during the live SSE stream.
|
||||
* When the post-stream refetch returns the redacted persisted message, the
|
||||
* selector re-injects the captured values so the user can still copy the
|
||||
* key they just generated. Cache is dropped on page unload.
|
||||
*/
|
||||
|
||||
const CREDENTIAL_TAG_PATTERN = /<credential>([\s\S]*?)<\/credential>/g
|
||||
const REDACTED_TAG_PATTERN = /<credential>[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/
|
||||
const SIM_KEY_TYPE = 'sim_key'
|
||||
const REDACTED_SIM_KEY_TAG = `<credential>${JSON.stringify({
|
||||
type: SIM_KEY_TYPE,
|
||||
redacted: true,
|
||||
})}</credential>`
|
||||
|
||||
interface CredentialTagBody {
|
||||
type?: unknown
|
||||
value?: unknown
|
||||
redacted?: unknown
|
||||
}
|
||||
|
||||
function parseCredentialBody(body: string): CredentialTagBody | null {
|
||||
try {
|
||||
return JSON.parse(body) as CredentialTagBody
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasRedactedSimKeyTag(content: string | undefined): boolean {
|
||||
return typeof content === 'string' && REDACTED_TAG_PATTERN.test(content)
|
||||
}
|
||||
|
||||
// Write side ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace every revealed `<credential type="sim_key">` tag in `content` with a
|
||||
* placeholder marked `redacted: true`. Other credential types (e.g. OAuth
|
||||
* `link`) and malformed bodies pass through unchanged.
|
||||
*/
|
||||
export function redactSensitiveContent<T extends string | undefined>(content: T): T {
|
||||
if (typeof content !== 'string' || !content.includes('<credential>')) return content
|
||||
return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => {
|
||||
const parsed = parseCredentialBody(body)
|
||||
return parsed?.type === SIM_KEY_TYPE ? REDACTED_SIM_KEY_TAG : match
|
||||
}) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the raw `key` field in a `generate_api_key` tool result with the
|
||||
* shared redaction marker. The persisted tool result still records the
|
||||
* call's outcome and metadata; only the secret is stripped.
|
||||
*/
|
||||
export function redactToolCallResult(
|
||||
toolName: string | undefined,
|
||||
result: { success: boolean; output?: unknown; error?: string } | undefined
|
||||
): { success: boolean; output?: unknown; error?: string } | undefined {
|
||||
if (!result || toolName !== GenerateApiKey.id) return result
|
||||
const output = result.output
|
||||
if (!output || typeof output !== 'object') return result
|
||||
const record = output as Record<string, unknown>
|
||||
if (typeof record.key !== 'string') return result
|
||||
return {
|
||||
...result,
|
||||
output: { ...record, key: REDACTED_MARKER, redacted: true },
|
||||
}
|
||||
}
|
||||
|
||||
function isMergeableAssistantTextBlock(block: PersistedContentBlock): boolean {
|
||||
return (
|
||||
block.type === MothershipStreamV1EventType.text &&
|
||||
block.channel === MothershipStreamV1TextChannel.assistant &&
|
||||
block.toolCall === undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming produces one assistant-text block per token chunk, which means a
|
||||
* `<credential>...</credential>` tag can straddle dozens of blocks. Per-block
|
||||
* redaction can't see across that boundary and would persist the secret. So
|
||||
* coalesce consecutive same-lane assistant-text blocks into a single block,
|
||||
* then redact the merged content.
|
||||
*
|
||||
* Block timestamps for assistant text aren't user-visible (only `thinking`
|
||||
* blocks drive the "Thought for Ns" chip), so collapsing the run is safe.
|
||||
*/
|
||||
export function mergeAndRedactPersistedBlocks(
|
||||
blocks: PersistedContentBlock[]
|
||||
): PersistedContentBlock[] {
|
||||
const out: PersistedContentBlock[] = []
|
||||
let runStart = -1
|
||||
let runLane: PersistedContentBlock['lane']
|
||||
|
||||
const flushRun = (endExclusive: number) => {
|
||||
if (runStart < 0) return
|
||||
const run = blocks.slice(runStart, endExclusive)
|
||||
runStart = -1
|
||||
if (run.length === 0) return
|
||||
if (run.length === 1) {
|
||||
const single = run[0]
|
||||
out.push({ ...single, content: redactSensitiveContent(single.content) })
|
||||
return
|
||||
}
|
||||
const head = run[0]
|
||||
const tail = run[run.length - 1]
|
||||
out.push({
|
||||
...head,
|
||||
content: redactSensitiveContent(run.map((b) => b.content ?? '').join('')),
|
||||
...(tail.endedAt !== undefined ? { endedAt: tail.endedAt } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
const sameRun = runStart >= 0 && isMergeableAssistantTextBlock(block) && runLane === block.lane
|
||||
if (sameRun) continue
|
||||
flushRun(i)
|
||||
if (isMergeableAssistantTextBlock(block)) {
|
||||
runStart = i
|
||||
runLane = block.lane
|
||||
} else {
|
||||
out.push(block)
|
||||
}
|
||||
}
|
||||
flushRun(blocks.length)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Read side ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Page-session cache of `sim_key` credential values revealed during the live
|
||||
* SSE stream, keyed by either the synthetic live-assistant id (used while
|
||||
* streaming) or the persisted message's `requestId` (used after refetch).
|
||||
* Lives in a `useRef`; never persisted; dropped on unload.
|
||||
*/
|
||||
export type RevealedSimKeysByMessage = Map<string, string[]>
|
||||
|
||||
/**
|
||||
* Scan an assembled assistant message for `<credential type="sim_key">` tags
|
||||
* and return their values in stream order, skipping anything already redacted.
|
||||
*/
|
||||
export function extractRevealedSimKeys(content: string): string[] {
|
||||
if (!content || !content.includes('<credential>')) return []
|
||||
const values: string[] = []
|
||||
for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) {
|
||||
const parsed = parseCredentialBody(match[1])
|
||||
if (parsed?.type === SIM_KEY_TYPE && !parsed.redacted && typeof parsed.value === 'string') {
|
||||
values.push(parsed.value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the cache entries for the given keys with any newly-revealed values.
|
||||
* Each key in `keys` is written the same array — passing both the live-stream
|
||||
* id and the persisted `requestId` lets the post-finalize refetch hit the
|
||||
* cache after the message is renamed to its real UUID. The longest captured
|
||||
* list wins so a rerun that surfaces fewer values can't shrink the entry.
|
||||
*/
|
||||
export function captureRevealedSimKeys(
|
||||
cache: RevealedSimKeysByMessage,
|
||||
keys: ReadonlyArray<string | undefined>,
|
||||
content: string
|
||||
): void {
|
||||
if (!content.includes('<credential>')) return
|
||||
const next = extractRevealedSimKeys(content)
|
||||
if (next.length === 0) return
|
||||
for (const key of keys) {
|
||||
if (!key) continue
|
||||
const existing = cache.get(key)
|
||||
if (!existing || next.length > existing.length) cache.set(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreInString(
|
||||
content: string,
|
||||
revealedValues: string[],
|
||||
startCursor: number
|
||||
): {
|
||||
next: string
|
||||
changed: boolean
|
||||
cursor: number
|
||||
} {
|
||||
if (!content.includes('<credential>') || revealedValues.length === 0) {
|
||||
return { next: content, changed: false, cursor: startCursor }
|
||||
}
|
||||
let cursor = startCursor
|
||||
let changed = false
|
||||
const next = content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => {
|
||||
const parsed = parseCredentialBody(body)
|
||||
if (parsed?.type === SIM_KEY_TYPE && parsed.redacted === true) {
|
||||
const value = revealedValues[cursor]
|
||||
cursor += 1
|
||||
if (typeof value === 'string') {
|
||||
changed = true
|
||||
return `<credential>${JSON.stringify({ value, type: SIM_KEY_TYPE })}</credential>`
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
return { next, changed, cursor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace redacted `sim_key` tags in a single message with the live values
|
||||
* captured for that message. Returns the original message reference unchanged
|
||||
* when there's nothing to substitute, so memoized children keep their identity.
|
||||
*/
|
||||
export function restoreRevealedSimKeysForMessage(
|
||||
message: ChatMessage,
|
||||
cache: RevealedSimKeysByMessage
|
||||
): ChatMessage {
|
||||
if (message.role !== 'assistant') return message
|
||||
const revealed =
|
||||
cache.get(message.id) ?? (message.requestId ? cache.get(message.requestId) : undefined)
|
||||
if (!revealed || revealed.length === 0) return message
|
||||
if (
|
||||
!hasRedactedSimKeyTag(message.content) &&
|
||||
!message.contentBlocks?.some((b) => hasRedactedSimKeyTag(b.content))
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
const restoredContent = restoreInString(message.content, revealed, 0)
|
||||
let blocksChanged = false
|
||||
let blockCursor = 0
|
||||
const nextBlocks: ContentBlock[] | undefined = message.contentBlocks?.map((block) => {
|
||||
if (!hasRedactedSimKeyTag(block.content)) return block
|
||||
const restored = restoreInString(block.content as string, revealed, blockCursor)
|
||||
blockCursor = restored.cursor
|
||||
if (!restored.changed) return block
|
||||
blocksChanged = true
|
||||
return { ...block, content: restored.next }
|
||||
})
|
||||
|
||||
if (!restoredContent.changed && !blocksChanged) return message
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: restoredContent.next,
|
||||
...(nextBlocks ? { contentBlocks: nextBlocks } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockAnd, mockEq, mockGetChatStreamLockOwners, mockSet, mockUpdate, mockWhere } = vi.hoisted(
|
||||
() => ({
|
||||
mockAnd: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
mockEq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
|
||||
mockGetChatStreamLockOwners: vi.fn(),
|
||||
mockSet: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { update: mockUpdate },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
copilotChats: {
|
||||
id: 'copilotChats.id',
|
||||
conversationId: 'copilotChats.conversationId',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: mockAnd,
|
||||
eq: mockEq,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/session', () => ({
|
||||
getChatStreamLockOwners: mockGetChatStreamLockOwners,
|
||||
}))
|
||||
|
||||
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
|
||||
|
||||
describe('reconcileChatStreamMarkers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSet.mockReturnValue({ where: mockWhere })
|
||||
mockUpdate.mockReturnValue({ set: mockSet })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
mockGetChatStreamLockOwners.mockResolvedValue({
|
||||
status: 'verified',
|
||||
ownersByChatId: new Map<string, string>(),
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a persisted stream marker when Redis verifies no lock owner exists', async () => {
|
||||
const markers = await reconcileChatStreamMarkers([
|
||||
{ chatId: 'chat-stuck', streamId: 'stream-orphaned' },
|
||||
])
|
||||
|
||||
expect(mockGetChatStreamLockOwners).toHaveBeenCalledWith(['chat-stuck'])
|
||||
expect(markers.get('chat-stuck')).toEqual({
|
||||
chatId: 'chat-stuck',
|
||||
streamId: null,
|
||||
status: 'inactive',
|
||||
})
|
||||
})
|
||||
|
||||
it('repairs a verified stale persisted stream marker when requested', async () => {
|
||||
await reconcileChatStreamMarkers([{ chatId: 'chat-stuck', streamId: 'stream-orphaned' }], {
|
||||
repairVerifiedStaleMarkers: true,
|
||||
})
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockSet).toHaveBeenCalledWith({ conversationId: null })
|
||||
expect(mockWhere).toHaveBeenCalledWith(
|
||||
mockAnd(
|
||||
mockEq('copilotChats.id', 'chat-stuck'),
|
||||
mockEq('copilotChats.conversationId', 'stream-orphaned')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the canonical Redis owner when the persisted stream marker is stale', async () => {
|
||||
mockGetChatStreamLockOwners.mockResolvedValueOnce({
|
||||
status: 'verified',
|
||||
ownersByChatId: new Map([['chat-mismatch', 'stream-live']]),
|
||||
})
|
||||
|
||||
const markers = await reconcileChatStreamMarkers([
|
||||
{ chatId: 'chat-mismatch', streamId: 'stream-stale' },
|
||||
])
|
||||
|
||||
expect(markers.get('chat-mismatch')).toEqual({
|
||||
chatId: 'chat-mismatch',
|
||||
streamId: 'stream-live',
|
||||
status: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves persisted stream markers when Redis state is unknown', async () => {
|
||||
mockGetChatStreamLockOwners.mockResolvedValueOnce({
|
||||
status: 'unknown',
|
||||
ownersByChatId: new Map<string, string>(),
|
||||
})
|
||||
|
||||
const markers = await reconcileChatStreamMarkers([
|
||||
{ chatId: 'chat-remote', streamId: 'stream-remote' },
|
||||
])
|
||||
|
||||
expect(markers.get('chat-remote')).toEqual({
|
||||
chatId: 'chat-remote',
|
||||
streamId: 'stream-remote',
|
||||
status: 'unknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a persisted marker when unknown local owner differs', async () => {
|
||||
mockGetChatStreamLockOwners.mockResolvedValueOnce({
|
||||
status: 'unknown',
|
||||
ownersByChatId: new Map([['chat-mismatch', 'stream-local']]),
|
||||
})
|
||||
|
||||
const markers = await reconcileChatStreamMarkers([
|
||||
{ chatId: 'chat-mismatch', streamId: 'stream-persisted' },
|
||||
])
|
||||
|
||||
expect(markers.get('chat-mismatch')).toEqual({
|
||||
chatId: 'chat-mismatch',
|
||||
streamId: 'stream-persisted',
|
||||
status: 'unknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a null persisted marker as inactive even when Redis still holds a lock (post-completion teardown window)', async () => {
|
||||
mockGetChatStreamLockOwners.mockResolvedValueOnce({
|
||||
status: 'verified',
|
||||
ownersByChatId: new Map([['chat-starting', 'stream-starting']]),
|
||||
})
|
||||
|
||||
const markers = await reconcileChatStreamMarkers([{ chatId: 'chat-starting', streamId: null }])
|
||||
|
||||
expect(markers.get('chat-starting')).toEqual({
|
||||
chatId: 'chat-starting',
|
||||
streamId: null,
|
||||
status: 'inactive',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not query locks when no chats have persisted stream markers', async () => {
|
||||
const markers = await reconcileChatStreamMarkers([{ chatId: 'chat-idle', streamId: null }])
|
||||
|
||||
expect(markers.get('chat-idle')).toEqual({
|
||||
chatId: 'chat-idle',
|
||||
streamId: null,
|
||||
status: 'inactive',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getChatStreamLockOwners } from '@/lib/copilot/request/session'
|
||||
|
||||
const logger = createLogger('ChatStreamLiveness')
|
||||
|
||||
export interface ChatStreamMarkerCandidate {
|
||||
chatId: string
|
||||
streamId: string | null
|
||||
}
|
||||
|
||||
export interface ReconciledChatStreamMarker {
|
||||
chatId: string
|
||||
streamId: string | null
|
||||
status: 'active' | 'inactive' | 'unknown'
|
||||
}
|
||||
|
||||
interface ReconcileChatStreamMarkersOptions {
|
||||
repairVerifiedStaleMarkers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles persisted chat stream markers against the runtime stream lock.
|
||||
*
|
||||
* Redis lock ownership is the canonical live-stream signal. When the lookup is
|
||||
* verified, missing owners clear stale persisted markers and present owners win
|
||||
* over stale DB values. When Redis state is unknown, persisted markers are
|
||||
* preserved so a transient Redis failure in a multi-pod deployment does not
|
||||
* incorrectly hide a live stream owned by another pod.
|
||||
*/
|
||||
export async function reconcileChatStreamMarkers(
|
||||
candidates: ChatStreamMarkerCandidate[],
|
||||
options: ReconcileChatStreamMarkersOptions = {}
|
||||
): Promise<Map<string, ReconciledChatStreamMarker>> {
|
||||
const results = new Map<string, ReconciledChatStreamMarker>()
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.streamId === null) {
|
||||
results.set(candidate.chatId, {
|
||||
chatId: candidate.chatId,
|
||||
streamId: null,
|
||||
status: 'inactive',
|
||||
})
|
||||
continue
|
||||
}
|
||||
results.set(candidate.chatId, {
|
||||
chatId: candidate.chatId,
|
||||
streamId: candidate.streamId,
|
||||
status: 'unknown',
|
||||
})
|
||||
}
|
||||
|
||||
const candidatesWithMarkers = candidates.filter((candidate) => candidate.streamId !== null)
|
||||
if (candidatesWithMarkers.length === 0) {
|
||||
return results
|
||||
}
|
||||
|
||||
const { status, ownersByChatId } = await getChatStreamLockOwners(
|
||||
candidatesWithMarkers.map((candidate) => candidate.chatId)
|
||||
)
|
||||
|
||||
for (const candidate of candidatesWithMarkers) {
|
||||
const owner = ownersByChatId.get(candidate.chatId)
|
||||
if (owner && (status === 'verified' || owner === candidate.streamId)) {
|
||||
results.set(candidate.chatId, {
|
||||
chatId: candidate.chatId,
|
||||
streamId: owner,
|
||||
status: 'active',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (status === 'verified') {
|
||||
results.set(candidate.chatId, {
|
||||
chatId: candidate.chatId,
|
||||
streamId: null,
|
||||
status: 'inactive',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results.set(candidate.chatId, {
|
||||
chatId: candidate.chatId,
|
||||
streamId: candidate.streamId,
|
||||
status: 'unknown',
|
||||
})
|
||||
}
|
||||
|
||||
if (options.repairVerifiedStaleMarkers) {
|
||||
await repairVerifiedStaleMarkers(candidates, results)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function repairVerifiedStaleMarkers(
|
||||
candidates: ChatStreamMarkerCandidate[],
|
||||
results: Map<string, ReconciledChatStreamMarker>
|
||||
): Promise<void> {
|
||||
const staleCandidates = candidates.filter(
|
||||
(candidate): candidate is { chatId: string; streamId: string } => {
|
||||
const result = results.get(candidate.chatId)
|
||||
return (
|
||||
candidate.streamId !== null && result?.status === 'inactive' && result.streamId === null
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (staleCandidates.length === 0) return
|
||||
|
||||
await Promise.all(
|
||||
staleCandidates.map(async (candidate) => {
|
||||
try {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ conversationId: null })
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, candidate.chatId),
|
||||
eq(copilotChats.conversationId, candidate.streamId)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to repair stale chat stream marker', {
|
||||
chatId: candidate.chatId,
|
||||
streamId: candidate.streamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
|
||||
type TerminalToolOutcome =
|
||||
| typeof MothershipStreamV1ToolOutcome.success
|
||||
| typeof MothershipStreamV1ToolOutcome.error
|
||||
| typeof MothershipStreamV1ToolOutcome.cancelled
|
||||
| typeof MothershipStreamV1ToolOutcome.skipped
|
||||
| typeof MothershipStreamV1ToolOutcome.rejected
|
||||
|
||||
interface ResolveStreamToolOutcomeParams {
|
||||
output?: unknown
|
||||
status?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
export function resolveStreamToolOutcome({
|
||||
output,
|
||||
status,
|
||||
success,
|
||||
}: ResolveStreamToolOutcomeParams): TerminalToolOutcome {
|
||||
const outputRecord = isRecordLike(output) ? output : undefined
|
||||
const isCancelled =
|
||||
outputRecord?.reason === 'user_cancelled' ||
|
||||
outputRecord?.cancelledByUser === true ||
|
||||
status === MothershipStreamV1ToolOutcome.cancelled
|
||||
|
||||
if (isCancelled) {
|
||||
return MothershipStreamV1ToolOutcome.cancelled
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case MothershipStreamV1ToolOutcome.success:
|
||||
case MothershipStreamV1ToolOutcome.error:
|
||||
case MothershipStreamV1ToolOutcome.skipped:
|
||||
case MothershipStreamV1ToolOutcome.rejected:
|
||||
return status
|
||||
case 'aborted':
|
||||
return MothershipStreamV1ToolOutcome.cancelled
|
||||
case 'failed':
|
||||
return MothershipStreamV1ToolOutcome.error
|
||||
default:
|
||||
return success === true
|
||||
? MothershipStreamV1ToolOutcome.success
|
||||
: MothershipStreamV1ToolOutcome.error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { mockAppendCopilotChatMessages } = vi.hoisted(() => ({
|
||||
mockAppendCopilotChatMessages: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/messages-store', () => ({
|
||||
appendCopilotChatMessages: mockAppendCopilotChatMessages,
|
||||
}))
|
||||
|
||||
import { finalizeAssistantTurn } from './terminal-state'
|
||||
|
||||
const assistantMessage = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant' as const,
|
||||
content: 'hi',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence the two in-tx reads: the chat row (`FOR UPDATE ... LIMIT 1`) and the
|
||||
* last-message lookup that drives dedup — both terminate on `.limit(1)`.
|
||||
*/
|
||||
function mockReads(opts: {
|
||||
chat: Record<string, unknown> | null
|
||||
last?: { messageId: string; role: string }
|
||||
}) {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : [])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : [])
|
||||
}
|
||||
|
||||
describe('finalizeAssistantTurn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
|
||||
dbChainMockFns.limit.mockReset()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('appends the assistant message when the user turn has no reply yet', async () => {
|
||||
mockReads({
|
||||
chat: { conversationId: 'user-1', workspaceId: 'ws-1', model: null },
|
||||
last: { messageId: 'user-1', role: 'user' },
|
||||
})
|
||||
|
||||
const result = await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
assistantMessage,
|
||||
})
|
||||
|
||||
expect(result.appendedAssistant).toBe(true)
|
||||
const updateArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(updateArg).toEqual(
|
||||
expect.objectContaining({ updatedAt: expect.any(Date), conversationId: null })
|
||||
)
|
||||
expect(Object.hasOwn(updateArg, 'messages')).toBe(false)
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1'))
|
||||
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
|
||||
expect(mockAppendCopilotChatMessages).toHaveBeenCalledWith(
|
||||
'chat-1',
|
||||
[assistantMessage],
|
||||
{ streamId: 'user-1', chatModel: null },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('only clears the active stream marker when a response is already persisted', async () => {
|
||||
mockReads({
|
||||
chat: { conversationId: 'user-1', workspaceId: 'ws-1', model: null },
|
||||
last: { messageId: 'assistant-1', role: 'assistant' },
|
||||
})
|
||||
|
||||
const result = await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
assistantMessage: { ...assistantMessage, id: 'assistant-2' },
|
||||
})
|
||||
|
||||
expect(result.outcome).toBe('assistant_already_persisted')
|
||||
const updateArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(updateArg).toEqual(
|
||||
expect.objectContaining({ updatedAt: expect.any(Date), conversationId: null })
|
||||
)
|
||||
expect(Object.hasOwn(updateArg, 'messages')).toBe(false)
|
||||
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends a stopped assistant when the stream marker was already cleared', async () => {
|
||||
mockReads({
|
||||
chat: { conversationId: null, workspaceId: 'ws-1', model: null },
|
||||
last: { messageId: 'user-1', role: 'user' },
|
||||
})
|
||||
|
||||
const result = await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
streamMarkerPolicy: 'active-or-cleared',
|
||||
assistantMessage,
|
||||
})
|
||||
|
||||
expect(result.appendedAssistant).toBe(true)
|
||||
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not append on a cleared marker unless the policy allows it', async () => {
|
||||
mockReads({ chat: { conversationId: null, workspaceId: 'ws-1', model: null } })
|
||||
|
||||
const result = await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
assistantMessage,
|
||||
})
|
||||
|
||||
expect(result.updated).toBe(false)
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports already persisted when a cleared marker races with a duplicate stop', async () => {
|
||||
mockReads({
|
||||
chat: { conversationId: null, workspaceId: 'ws-1', model: null },
|
||||
last: { messageId: 'assistant-1', role: 'assistant' },
|
||||
})
|
||||
|
||||
const result = await finalizeAssistantTurn({
|
||||
chatId: 'chat-1',
|
||||
userMessageId: 'user-1',
|
||||
streamMarkerPolicy: 'active-or-cleared',
|
||||
assistantMessage: { ...assistantMessage, id: 'assistant-2' },
|
||||
})
|
||||
|
||||
expect(result.updated).toBe(false)
|
||||
expect(result.outcome).toBe('assistant_already_persisted')
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats, copilotMessages } from '@sim/db/schema'
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
|
||||
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
|
||||
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import { CopilotChatFinalizeOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
|
||||
type StreamMarkerPolicy = 'active-only' | 'active-or-cleared'
|
||||
|
||||
interface FinalizeAssistantTurnParams {
|
||||
chatId: string
|
||||
userMessageId: string
|
||||
userId?: string
|
||||
assistantMessage?: PersistedMessage
|
||||
streamMarkerPolicy?: StreamMarkerPolicy
|
||||
}
|
||||
|
||||
export interface FinalizeAssistantTurnResult {
|
||||
found: boolean
|
||||
updated: boolean
|
||||
appendedAssistant: boolean
|
||||
workspaceId?: string | null
|
||||
outcome: (typeof CopilotChatFinalizeOutcome)[keyof typeof CopilotChatFinalizeOutcome]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active stream marker for a chat and optionally append the assistant
|
||||
* message if a response has not already been persisted immediately after the
|
||||
* triggering user message.
|
||||
*/
|
||||
export async function finalizeAssistantTurn({
|
||||
chatId,
|
||||
userMessageId,
|
||||
userId,
|
||||
assistantMessage,
|
||||
streamMarkerPolicy = 'active-only',
|
||||
}: FinalizeAssistantTurnParams): Promise<FinalizeAssistantTurnResult> {
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotChatFinalizeAssistantTurn,
|
||||
{
|
||||
[TraceAttr.DbSystem]: 'postgresql',
|
||||
[TraceAttr.DbSqlTable]: 'copilot_chats',
|
||||
[TraceAttr.ChatId]: chatId,
|
||||
[TraceAttr.ChatUserMessageId]: userMessageId,
|
||||
[TraceAttr.ChatHasAssistantMessage]: !!assistantMessage,
|
||||
},
|
||||
async (span) => {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const where = userId
|
||||
? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))
|
||||
: eq(copilotChats.id, chatId)
|
||||
const [row] = await tx
|
||||
.select({
|
||||
conversationId: copilotChats.conversationId,
|
||||
workspaceId: copilotChats.workspaceId,
|
||||
model: copilotChats.model,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(where)
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
found: false,
|
||||
updated: false,
|
||||
appendedAssistant: false,
|
||||
workspaceId: null,
|
||||
outcome: CopilotChatFinalizeOutcome.StaleUserMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const chatModel = row.model ?? null
|
||||
|
||||
const markerMatches = row.conversationId === userMessageId
|
||||
const markerAlreadyCleared = row.conversationId === null
|
||||
const ownsTurn =
|
||||
markerMatches || (streamMarkerPolicy === 'active-or-cleared' && markerAlreadyCleared)
|
||||
if (!ownsTurn) {
|
||||
return {
|
||||
found: true,
|
||||
updated: false,
|
||||
appendedAssistant: false,
|
||||
workspaceId: row.workspaceId,
|
||||
outcome: CopilotChatFinalizeOutcome.StaleUserMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// Append only when the user message is still the last row: anything
|
||||
// after it means the turn already has a response (dedup under the lock).
|
||||
const [lastMessage] = await tx
|
||||
.select({ messageId: copilotMessages.messageId, role: copilotMessages.role })
|
||||
.from(copilotMessages)
|
||||
.where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt)))
|
||||
.orderBy(
|
||||
sql`${copilotMessages.seq} desc nulls last`,
|
||||
desc(copilotMessages.createdAt),
|
||||
desc(copilotMessages.id)
|
||||
)
|
||||
.limit(1)
|
||||
const canAppendAssistant = lastMessage?.messageId === userMessageId
|
||||
const alreadyHasResponse = lastMessage?.role === 'assistant'
|
||||
|
||||
const updateWhere = userId
|
||||
? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))
|
||||
: eq(copilotChats.id, chatId)
|
||||
const baseUpdate = {
|
||||
conversationId: null,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (assistantMessage && canAppendAssistant) {
|
||||
await tx.update(copilotChats).set(baseUpdate).where(updateWhere)
|
||||
await appendCopilotChatMessages(
|
||||
chatId,
|
||||
[assistantMessage],
|
||||
{ streamId: userMessageId, chatModel },
|
||||
tx
|
||||
)
|
||||
return {
|
||||
found: true,
|
||||
updated: true,
|
||||
appendedAssistant: true,
|
||||
workspaceId: row.workspaceId,
|
||||
outcome: CopilotChatFinalizeOutcome.AppendedAssistant,
|
||||
}
|
||||
}
|
||||
|
||||
if (markerMatches) {
|
||||
await tx.update(copilotChats).set(baseUpdate).where(updateWhere)
|
||||
return {
|
||||
found: true,
|
||||
updated: true,
|
||||
appendedAssistant: false,
|
||||
workspaceId: row.workspaceId,
|
||||
outcome: assistantMessage
|
||||
? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
|
||||
: CopilotChatFinalizeOutcome.ClearedStreamMarkerOnly,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
updated: false,
|
||||
appendedAssistant: false,
|
||||
workspaceId: row.workspaceId,
|
||||
outcome: alreadyHasResponse
|
||||
? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
|
||||
: CopilotChatFinalizeOutcome.StaleUserMessage,
|
||||
}
|
||||
})
|
||||
|
||||
span.setAttribute(TraceAttr.ChatFinalizeOutcome, result.outcome)
|
||||
return result
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: {} }))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
knowledgeBase: {},
|
||||
knowledgeConnector: {},
|
||||
mcpServers: {},
|
||||
userTableDefinitions: {},
|
||||
userTableRows: {},
|
||||
workflow: {},
|
||||
workflowFolder: {},
|
||||
workflowSchedule: {},
|
||||
}))
|
||||
|
||||
import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils'
|
||||
import { buildVfsSnapshot, buildWorkspaceMd, type WorkspaceMdData } from './workspace-context'
|
||||
|
||||
function baseData(overrides: Partial<WorkspaceMdData> = {}): WorkspaceMdData {
|
||||
return {
|
||||
workspace: { id: 'ws-1', name: 'WS', ownerId: 'u-1' },
|
||||
members: [],
|
||||
workflows: [],
|
||||
knowledgeBases: [],
|
||||
tables: [],
|
||||
files: [],
|
||||
oauthIntegrations: [],
|
||||
envVariables: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildWorkspaceMd - workflow VFS state paths', () => {
|
||||
// `workflows[].folderPath` arrives ALREADY per-segment percent-encoded (it is
|
||||
// the value from buildVfsFolderPathMap / resolveFolderPath that also builds the
|
||||
// stored VFS keys). The advertised path must not re-encode it.
|
||||
it('emits a single-encoded state path for a folder name with a space', () => {
|
||||
const md = buildWorkspaceMd(
|
||||
baseData({
|
||||
workflows: [
|
||||
{ id: 'wf-1', name: 'The Elder', isDeployed: false, folderPath: 'The%20Elder' },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(md).toContain('workflows/The%20Elder/The%20Elder/state.json')
|
||||
// The exact double-encoding regression: `%20` -> `%2520`.
|
||||
expect(md).not.toContain('The%2520Elder')
|
||||
})
|
||||
|
||||
it('matches the canonical VFS dir helper the materializer/pointers use', () => {
|
||||
const folderPath = 'My%20Folder/Sub%20Folder'
|
||||
const md = buildWorkspaceMd(
|
||||
baseData({
|
||||
workflows: [{ id: 'wf-1', name: 'My Flow', isDeployed: false, folderPath }],
|
||||
})
|
||||
)
|
||||
|
||||
const expected = `${canonicalWorkflowVfsDir({ name: 'My Flow', folderPath })}/state.json`
|
||||
expect(expected).toBe('workflows/My%20Folder/Sub%20Folder/My%20Flow/state.json')
|
||||
expect(md).toContain(expected)
|
||||
})
|
||||
|
||||
it('advertises canonical encoded VFS paths for root-level workflows', () => {
|
||||
const md = buildWorkspaceMd(
|
||||
baseData({
|
||||
workflows: [{ id: 'wf-1', name: 'Root Flow', isDeployed: false, folderPath: null }],
|
||||
})
|
||||
)
|
||||
|
||||
expect(md).toContain('VFS dir: `workflows/Root%20Flow`')
|
||||
expect(md).toContain('VFS state path: `workflows/Root%20Flow/state.json`')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkspaceMd - connected integrations / credentials', () => {
|
||||
it('lists each connected account with its credentialId and never leaks tokens', () => {
|
||||
const md = buildWorkspaceMd(
|
||||
baseData({
|
||||
oauthIntegrations: [
|
||||
{
|
||||
id: 'cred-abc',
|
||||
providerId: 'google-email',
|
||||
displayName: 'alice@example.com',
|
||||
role: 'admin',
|
||||
},
|
||||
{ id: 'cred-def', providerId: 'slack', displayName: 'Workspace Bot', role: 'member' },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// credentialId must be present so the superagent can pass it without reading credentials.json.
|
||||
expect(md).toContain('credentialId: `cred-abc`')
|
||||
expect(md).toContain('credentialId: `cred-def`')
|
||||
expect(md).toContain('google-email')
|
||||
expect(md).toContain('slack')
|
||||
|
||||
// No OAuth secrets/tokens may ever appear in the workspace context.
|
||||
for (const secret of [
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'idToken',
|
||||
'clientSecret',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
]) {
|
||||
expect(md).not.toContain(secret)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders (none) when no integrations are connected', () => {
|
||||
const md = buildWorkspaceMd(baseData({ oauthIntegrations: [] }))
|
||||
expect(md).toContain('## Connected Integrations\n(none)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => {
|
||||
it('is byte-identical regardless of input row order', () => {
|
||||
const a = buildWorkspaceMd(
|
||||
baseData({
|
||||
members: [
|
||||
{ name: 'Bob', email: 'bob@x.com', permissionType: 'admin' },
|
||||
{ name: 'Amy', email: 'amy@x.com', permissionType: 'write' },
|
||||
],
|
||||
workflows: [
|
||||
{ id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null },
|
||||
{ id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null },
|
||||
],
|
||||
tables: [
|
||||
{ id: 't-2', name: 'Orders', description: null, rowCount: 5 },
|
||||
{ id: 't-1', name: 'Customers', description: null, rowCount: 9 },
|
||||
],
|
||||
knowledgeBases: [
|
||||
{ id: 'kb-2', name: 'Docs', connectorTypes: ['notion', 'github'] },
|
||||
{ id: 'kb-1', name: 'Articles', connectorTypes: ['github', 'notion'] },
|
||||
],
|
||||
oauthIntegrations: [
|
||||
{ id: 'c-2', providerId: 'slack', displayName: null, role: null },
|
||||
{ id: 'c-1', providerId: 'github', displayName: null, role: null },
|
||||
],
|
||||
envVariables: ['ZED', 'API_KEY'],
|
||||
customTools: [
|
||||
{ id: 'ct-2', name: 'Beta Tool' },
|
||||
{ id: 'ct-1', name: 'Alpha Tool' },
|
||||
],
|
||||
mcpServers: [
|
||||
{ id: 'mcp-2', name: 'Zulu', url: null, enabled: false },
|
||||
{ id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true },
|
||||
],
|
||||
skills: [
|
||||
{ id: 'sk-2', name: 'Writer', description: 'writes' },
|
||||
{ id: 'sk-1', name: 'Editor', description: 'edits' },
|
||||
],
|
||||
jobs: [
|
||||
{
|
||||
id: 'j-2',
|
||||
title: 'Nightly',
|
||||
prompt: 'run nightly',
|
||||
cronExpression: '0 0 * * *',
|
||||
status: 'active',
|
||||
lifecycle: 'persistent',
|
||||
sourceTaskName: null,
|
||||
},
|
||||
{
|
||||
id: 'j-1',
|
||||
title: 'Hourly',
|
||||
prompt: 'run hourly',
|
||||
cronExpression: '0 * * * *',
|
||||
status: 'active',
|
||||
lifecycle: 'persistent',
|
||||
sourceTaskName: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
const b = buildWorkspaceMd(
|
||||
baseData({
|
||||
members: [
|
||||
{ name: 'Amy', email: 'amy@x.com', permissionType: 'write' },
|
||||
{ name: 'Bob', email: 'bob@x.com', permissionType: 'admin' },
|
||||
],
|
||||
workflows: [
|
||||
{ id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null },
|
||||
{ id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null },
|
||||
],
|
||||
tables: [
|
||||
{ id: 't-1', name: 'Customers', description: null, rowCount: 9 },
|
||||
{ id: 't-2', name: 'Orders', description: null, rowCount: 5 },
|
||||
],
|
||||
knowledgeBases: [
|
||||
{ id: 'kb-1', name: 'Articles', connectorTypes: ['notion', 'github'] },
|
||||
{ id: 'kb-2', name: 'Docs', connectorTypes: ['github', 'notion'] },
|
||||
],
|
||||
oauthIntegrations: [
|
||||
{ id: 'c-1', providerId: 'github', displayName: null, role: null },
|
||||
{ id: 'c-2', providerId: 'slack', displayName: null, role: null },
|
||||
],
|
||||
envVariables: ['API_KEY', 'ZED'],
|
||||
customTools: [
|
||||
{ id: 'ct-1', name: 'Alpha Tool' },
|
||||
{ id: 'ct-2', name: 'Beta Tool' },
|
||||
],
|
||||
mcpServers: [
|
||||
{ id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true },
|
||||
{ id: 'mcp-2', name: 'Zulu', url: null, enabled: false },
|
||||
],
|
||||
skills: [
|
||||
{ id: 'sk-1', name: 'Editor', description: 'edits' },
|
||||
{ id: 'sk-2', name: 'Writer', description: 'writes' },
|
||||
],
|
||||
jobs: [
|
||||
{
|
||||
id: 'j-1',
|
||||
title: 'Hourly',
|
||||
prompt: 'run hourly',
|
||||
cronExpression: '0 * * * *',
|
||||
status: 'active',
|
||||
lifecycle: 'persistent',
|
||||
sourceTaskName: null,
|
||||
},
|
||||
{
|
||||
id: 'j-2',
|
||||
title: 'Nightly',
|
||||
prompt: 'run nightly',
|
||||
cronExpression: '0 0 * * *',
|
||||
status: 'active',
|
||||
lifecycle: 'persistent',
|
||||
sourceTaskName: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('ignores volatile workflow run timestamps', () => {
|
||||
const withRun = buildWorkspaceMd(
|
||||
baseData({
|
||||
workflows: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Alpha',
|
||||
isDeployed: false,
|
||||
folderPath: null,
|
||||
lastRunAt: new Date('2026-06-18T12:00:00Z'),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
const withoutRun = buildWorkspaceMd(
|
||||
baseData({
|
||||
workflows: [{ id: 'wf-1', name: 'Alpha', isDeployed: false, folderPath: null }],
|
||||
})
|
||||
)
|
||||
expect(withRun).toBe(withoutRun)
|
||||
expect(withRun).not.toContain('last run')
|
||||
})
|
||||
|
||||
it('ignores volatile table row counts', () => {
|
||||
const a = buildWorkspaceMd(
|
||||
baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 1 }] })
|
||||
)
|
||||
const b = buildWorkspaceMd(
|
||||
baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 9999 }] })
|
||||
)
|
||||
expect(a).toBe(b)
|
||||
expect(a).not.toContain('rows')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom blocks', () => {
|
||||
const customBlocks = [
|
||||
{ type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' },
|
||||
]
|
||||
|
||||
it('renders a Custom Blocks section in the workspace markdown', () => {
|
||||
const md = buildWorkspaceMd(baseData({ customBlocks }))
|
||||
expect(md).toContain('## Custom Blocks (1)')
|
||||
expect(md).toContain('- **Invoice Parser** (custom_block_abc) — Parses invoices')
|
||||
})
|
||||
|
||||
it('omits the section when there are no custom blocks', () => {
|
||||
expect(buildWorkspaceMd(baseData())).not.toContain('## Custom Blocks')
|
||||
})
|
||||
|
||||
it('never leaks custom blocks into the typed snapshot Go diffs (diff-safety)', () => {
|
||||
const withBlocks = buildVfsSnapshot(baseData({ customBlocks }))
|
||||
const without = buildVfsSnapshot(baseData())
|
||||
expect('customBlocks' in withBlocks).toBe(false)
|
||||
expect(JSON.stringify(withBlocks)).toBe(JSON.stringify(without))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,659 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
knowledgeBase,
|
||||
knowledgeConnector,
|
||||
mcpServers,
|
||||
userTableDefinitions,
|
||||
workflow,
|
||||
workflowFolder,
|
||||
workflowSchedule,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type {
|
||||
VfsSnapshotV1,
|
||||
VfsSnapshotV1Job,
|
||||
VfsSnapshotV1Workflow,
|
||||
} from '@/lib/copilot/generated/vfs-snapshot-v1'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
||||
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
|
||||
import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations'
|
||||
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import { listSkills } from '@/lib/workflows/skills/operations'
|
||||
import {
|
||||
assertActiveWorkspaceAccess,
|
||||
getUsersWithPermissions,
|
||||
getWorkspaceWithOwner,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceContext')
|
||||
|
||||
const PROVIDER_SERVICES: Record<string, string[]> = {
|
||||
google: ['Gmail', 'Sheets', 'Calendar', 'Drive'],
|
||||
'google-service-account': ['Gmail', 'Sheets', 'Calendar', 'Drive'],
|
||||
slack: ['Slack'],
|
||||
github: ['GitHub'],
|
||||
microsoft: ['Outlook', 'OneDrive'],
|
||||
linear: ['Linear'],
|
||||
notion: ['Notion'],
|
||||
stripe: ['Stripe'],
|
||||
airtable: ['Airtable'],
|
||||
jira: ['Jira'],
|
||||
confluence: ['Confluence'],
|
||||
}
|
||||
|
||||
export interface WorkspaceMdData {
|
||||
workspace: { id: string; name: string; ownerId: string } | null
|
||||
members: Array<{ name: string; email: string; permissionType: string }>
|
||||
workflows: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
isDeployed: boolean
|
||||
lastRunAt?: Date | null
|
||||
folderPath?: string | null
|
||||
}>
|
||||
knowledgeBases: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
connectorTypes?: string[]
|
||||
}>
|
||||
// rowCount is no longer rendered (it is volatile and would bust the cached
|
||||
// prompt prefix); kept optional so callers that still have it cheaply (the VFS
|
||||
// materializer via listTables) need not change, while generateWorkspaceContext
|
||||
// skips the per-table COUNT query entirely.
|
||||
tables: Array<{ id: string; name: string; description?: string | null; rowCount?: number }>
|
||||
files: Array<{ id: string; name: string; type: string; size: number; folderPath?: string | null }>
|
||||
oauthIntegrations: Array<{
|
||||
id: string
|
||||
providerId: string
|
||||
displayName?: string | null
|
||||
role?: string | null
|
||||
}>
|
||||
envVariables: string[]
|
||||
tasks?: Array<{ id: string; title: string; updatedAt: Date }>
|
||||
customTools?: Array<{ id: string; name: string }>
|
||||
customBlocks?: Array<{ type: string; name: string; description?: string }>
|
||||
mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }>
|
||||
skills?: Array<{ id: string; name: string; description: string }>
|
||||
jobs?: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
prompt: string
|
||||
cronExpression: string | null
|
||||
status: string
|
||||
lifecycle: string
|
||||
sourceTaskName: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic string ordering. The workspace inventory is placed in the
|
||||
* prompt-cache prefix (mothership), so its bytes must be identical for identical
|
||||
* workspace state regardless of DB row order — otherwise the cache silently
|
||||
* busts every turn. `localeCompare` with a pinned locale gives stable, readable
|
||||
* ordering across Sim instances (all run the same Node/ICU build).
|
||||
*/
|
||||
function stableCompare(a: string, b: string): number {
|
||||
return a.localeCompare(b, 'en')
|
||||
}
|
||||
|
||||
/** Stable order by display name, tie-broken by id, for inventory listings. */
|
||||
function byNameThenId(a: { name: string; id: string }, b: { name: string; id: string }): number {
|
||||
return stableCompare(a.name, b.name) || stableCompare(a.id, b.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure formatting: build WORKSPACE.md content from pre-fetched data.
|
||||
* No DB access — callers are responsible for providing the data.
|
||||
*
|
||||
* Output is deterministic: every collection is sorted by a stable key and
|
||||
* volatile fields (run timestamps, mutable row counts) are omitted, so the
|
||||
* rendered inventory only changes when the workspace structurally changes. This
|
||||
* is what lets the mothership cache it in the prompt prefix across turns.
|
||||
*/
|
||||
export function buildWorkspaceMd(data: WorkspaceMdData): string {
|
||||
const sections: string[] = []
|
||||
|
||||
if (data.workspace) {
|
||||
sections.push(
|
||||
`## Workspace\n- **Name**: ${data.workspace.name}\n- **ID**: ${data.workspace.id}\n- **Owner**: ${data.workspace.ownerId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (data.members.length > 0) {
|
||||
const lines = [...data.members]
|
||||
.sort((a, b) => stableCompare(a.email, b.email))
|
||||
.map((m) => {
|
||||
const display = m.name ? `${m.name} (${m.email})` : m.email
|
||||
return `- ${display} — ${m.permissionType}`
|
||||
})
|
||||
sections.push(`## Members\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
if (data.workflows.length > 0) {
|
||||
const rootWorkflows: typeof data.workflows = []
|
||||
const folderWorkflows = new Map<string, typeof data.workflows>()
|
||||
|
||||
for (const wf of data.workflows) {
|
||||
if (wf.folderPath) {
|
||||
const existing = folderWorkflows.get(wf.folderPath) ?? []
|
||||
existing.push(wf)
|
||||
folderWorkflows.set(wf.folderPath, existing)
|
||||
} else {
|
||||
rootWorkflows.push(wf)
|
||||
}
|
||||
}
|
||||
|
||||
const formatWf = (wf: (typeof data.workflows)[0], indent: string) => {
|
||||
const parts = [`${indent}- **${wf.name}** (${wf.id})`]
|
||||
const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath })
|
||||
parts.push(`${indent} VFS dir: \`${workflowDir}\``)
|
||||
parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``)
|
||||
if (wf.description) parts.push(`${indent} ${wf.description}`)
|
||||
// `deployed` is a structural flag (kept); `lastRunAt` is intentionally
|
||||
// omitted — it changes on every run and would bust the cached prompt
|
||||
// prefix that carries this inventory. Current run data lives in
|
||||
// workflows/{name}/executions.json.
|
||||
if (wf.isDeployed) parts[0] += ' — deployed'
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
'Use the canonical VFS dir/state path shown under each workflow. Paths are percent-encoded per segment; copy them verbatim and do not infer paths from display names.'
|
||||
)
|
||||
for (const wf of [...rootWorkflows].sort(byNameThenId)) {
|
||||
lines.push(formatWf(wf, ''))
|
||||
}
|
||||
const sortedFolders = [...folderWorkflows.entries()].sort((a, b) => stableCompare(a[0], b[0]))
|
||||
for (const [folder, wfs] of sortedFolders) {
|
||||
lines.push(`- 📁 **${folder}/**`)
|
||||
for (const wf of [...wfs].sort(byNameThenId)) {
|
||||
lines.push(formatWf(wf, ' '))
|
||||
}
|
||||
}
|
||||
sections.push(`## Workflows (${data.workflows.length})\n${lines.join('\n')}`)
|
||||
} else {
|
||||
sections.push('## Workflows (0)\n(none)')
|
||||
}
|
||||
|
||||
if (data.knowledgeBases.length > 0) {
|
||||
const lines = [...data.knowledgeBases].sort(byNameThenId).map((kb) => {
|
||||
let line = `- **${kb.name}** (${kb.id})`
|
||||
if (kb.description) line += ` — ${kb.description}`
|
||||
if (kb.connectorTypes && kb.connectorTypes.length > 0) {
|
||||
line += ` | connectors: ${[...kb.connectorTypes].sort(stableCompare).join(', ')}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
sections.push(`## Knowledge Bases (${data.knowledgeBases.length})\n${lines.join('\n')}`)
|
||||
} else {
|
||||
sections.push('## Knowledge Bases (0)\n(none)')
|
||||
}
|
||||
|
||||
if (data.tables.length > 0) {
|
||||
// rowCount is omitted: it changes on every row write and would bust the
|
||||
// cached prompt prefix. Live counts are in tables/{name}/meta.json.
|
||||
const lines = [...data.tables].sort(byNameThenId).map((t) => {
|
||||
let line = `- **${t.name}** (${t.id})`
|
||||
if (t.description) line += ` — ${t.description}`
|
||||
return line
|
||||
})
|
||||
sections.push(`## Tables (${data.tables.length})\n${lines.join('\n')}`)
|
||||
} else {
|
||||
sections.push('## Tables (0)\n(none)')
|
||||
}
|
||||
|
||||
if (data.files.length > 0) {
|
||||
const rootFiles: typeof data.files = []
|
||||
const folderFiles = new Map<string, typeof data.files>()
|
||||
for (const f of data.files) {
|
||||
if (f.folderPath) {
|
||||
const existing = folderFiles.get(f.folderPath) ?? []
|
||||
existing.push(f)
|
||||
folderFiles.set(f.folderPath, existing)
|
||||
} else {
|
||||
rootFiles.push(f)
|
||||
}
|
||||
}
|
||||
const fileLine = (f: (typeof data.files)[0], indent: string) => {
|
||||
const vfsPath = canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name })
|
||||
return `${indent}- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)} — \`${vfsPath}\``
|
||||
}
|
||||
const lines: string[] = [
|
||||
'Read or edit a file by the exact VFS path shown in backticks below — copy it verbatim (it is already percent-encoded) and append `/content` to read the contents. Do not retype the display name or re-encode the path.',
|
||||
]
|
||||
for (const f of [...rootFiles].sort(byNameThenId)) {
|
||||
lines.push(fileLine(f, ''))
|
||||
}
|
||||
const sortedFolders = [...folderFiles.entries()].sort((a, b) => stableCompare(a[0], b[0]))
|
||||
for (const [folder, folderFileList] of sortedFolders) {
|
||||
lines.push(`- 📁 **${folder}/**`)
|
||||
for (const f of [...folderFileList].sort(byNameThenId)) {
|
||||
lines.push(fileLine(f, ' '))
|
||||
}
|
||||
}
|
||||
sections.push(`## Files (${data.files.length})\n${lines.join('\n')}`)
|
||||
} else {
|
||||
sections.push('## Files (0)\n(none)')
|
||||
}
|
||||
|
||||
if (data.oauthIntegrations.length > 0) {
|
||||
const lines = [...data.oauthIntegrations]
|
||||
.sort((a, b) => stableCompare(a.providerId, b.providerId) || stableCompare(a.id, b.id))
|
||||
.map((c) => {
|
||||
const services = PROVIDER_SERVICES[c.providerId]
|
||||
const svc = services ? ` (${services.join(', ')})` : ''
|
||||
const who = c.displayName ? ` — ${c.displayName}` : ''
|
||||
const role = c.role ? `, ${c.role}` : ''
|
||||
return `- ${c.providerId}${svc}${who}${role} — credentialId: \`${c.id}\``
|
||||
})
|
||||
sections.push(
|
||||
`## Connected Integrations\nPass these credentialId values directly on OAuth tool calls — no need to read environment/credentials.json for them.\n${lines.join('\n')}`
|
||||
)
|
||||
} else {
|
||||
sections.push('## Connected Integrations\n(none)')
|
||||
}
|
||||
|
||||
if (data.envVariables.length > 0) {
|
||||
const lines = [...data.envVariables].sort(stableCompare).map((v) => `- ${v}`)
|
||||
sections.push(`## Environment Variables (${data.envVariables.length})\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
if (data.customTools && data.customTools.length > 0) {
|
||||
const lines = [...data.customTools].sort(byNameThenId).map((t) => `- **${t.name}** (${t.id})`)
|
||||
sections.push(`## Custom Tools (${data.customTools.length})\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
if (data.customBlocks && data.customBlocks.length > 0) {
|
||||
const lines = [...data.customBlocks]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((b) => `- **${b.name}** (${b.type})${b.description ? ` — ${b.description}` : ''}`)
|
||||
sections.push(`## Custom Blocks (${data.customBlocks.length})\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
if (data.mcpServers && data.mcpServers.length > 0) {
|
||||
const lines = [...data.mcpServers].sort(byNameThenId).map((s) => {
|
||||
const status = s.enabled ? 'enabled' : 'disabled'
|
||||
return `- **${s.name}** (${s.id}) — ${status}${s.url ? `, ${s.url}` : ''}`
|
||||
})
|
||||
sections.push(`## MCP Servers (${data.mcpServers.length})\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
if (data.skills && data.skills.length > 0) {
|
||||
const lines = [...data.skills]
|
||||
.sort(byNameThenId)
|
||||
.map((s) => `- **${s.name}** (${s.id}) — ${s.description}`)
|
||||
sections.push(
|
||||
`## Skills (${data.skills.length})\n` +
|
||||
'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' +
|
||||
lines.join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
if (data.jobs && data.jobs.length > 0) {
|
||||
const lines = [...data.jobs]
|
||||
.sort((a, b) => stableCompare(a.title || a.id, b.title || b.id) || stableCompare(a.id, b.id))
|
||||
.map((j) => {
|
||||
const displayName = j.title || j.id
|
||||
let line = `- **${displayName}** (${j.id}) — ${j.status}`
|
||||
if (j.lifecycle !== 'persistent') line += ` [${j.lifecycle}]`
|
||||
if (j.cronExpression) line += `, cron: ${j.cronExpression}`
|
||||
if (j.sourceTaskName) line += `, task: ${j.sourceTaskName}`
|
||||
const promptPreview = j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt
|
||||
line += `\n ${promptPreview}`
|
||||
return line
|
||||
})
|
||||
sections.push(`## Jobs (${data.jobs.length})\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
export function buildWorkspaceContextMd(data: WorkspaceMdData): string {
|
||||
return ['# Workspace Context', '', buildWorkspaceMd(data)].join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WORKSPACE.md content from actual database state.
|
||||
* Served as a top-level VFS file. The Go system prompt keeps only stable
|
||||
* discovery rules; the LLM reads dynamic workspace state from VFS files.
|
||||
* The LLM never writes this file directly.
|
||||
*/
|
||||
// Fetch + assemble the workspace inventory data once, from the PRIMARY db
|
||||
// (read-your-writes: a just-edited workflow is visible immediately, so the
|
||||
// injected snapshot can't lag behind a `glob`). Both the markdown inventory and
|
||||
// the typed VFS snapshot are built from this single fetch. Returns null when the
|
||||
// workspace is unavailable or a fetch fails.
|
||||
async function buildWorkspaceMdData(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceMdData | null> {
|
||||
try {
|
||||
await assertActiveWorkspaceAccess(workspaceId, userId)
|
||||
const wsRow = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!wsRow) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [
|
||||
members,
|
||||
workflows,
|
||||
folderRows,
|
||||
kbs,
|
||||
tables,
|
||||
files,
|
||||
credentials,
|
||||
customTools,
|
||||
mcpServerRows,
|
||||
skillRows,
|
||||
jobRows,
|
||||
customBlockSummaries,
|
||||
] = await Promise.all([
|
||||
getUsersWithPermissions(workspaceId),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
folderId: workflow.folderId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
name: workflowFolder.name,
|
||||
parentId: workflowFolder.parentId,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: knowledgeBase.id,
|
||||
name: knowledgeBase.name,
|
||||
description: knowledgeBase.description,
|
||||
})
|
||||
.from(knowledgeBase)
|
||||
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: userTableDefinitions.id,
|
||||
name: userTableDefinitions.name,
|
||||
description: userTableDefinitions.description,
|
||||
})
|
||||
.from(userTableDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableDefinitions.workspaceId, workspaceId),
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
),
|
||||
|
||||
listWorkspaceFiles(workspaceId),
|
||||
|
||||
getAccessibleOAuthCredentials(workspaceId, userId),
|
||||
|
||||
listCustomTools({ userId, workspaceId }),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: mcpServers.id,
|
||||
name: mcpServers.name,
|
||||
url: mcpServers.url,
|
||||
enabled: mcpServers.enabled,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))),
|
||||
|
||||
listSkills({ workspaceId, includeBuiltins: false }),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: workflowSchedule.id,
|
||||
jobTitle: workflowSchedule.jobTitle,
|
||||
prompt: workflowSchedule.prompt,
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
status: workflowSchedule.status,
|
||||
lifecycle: workflowSchedule.lifecycle,
|
||||
sourceTaskName: workflowSchedule.sourceTaskName,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
),
|
||||
|
||||
listCustomBlockSummariesForWorkspace(workspaceId),
|
||||
])
|
||||
|
||||
const kbIds = kbs.map((kb) => kb.id)
|
||||
const connectorRows =
|
||||
kbIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
|
||||
connectorType: knowledgeConnector.connectorType,
|
||||
})
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeConnector.knowledgeBaseId, kbIds),
|
||||
isNull(knowledgeConnector.archivedAt),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
: []
|
||||
const connectorTypesByKb = new Map<string, string[]>()
|
||||
for (const row of connectorRows) {
|
||||
const types = connectorTypesByKb.get(row.knowledgeBaseId) ?? []
|
||||
if (!types.includes(row.connectorType)) {
|
||||
types.push(row.connectorType)
|
||||
}
|
||||
connectorTypesByKb.set(row.knowledgeBaseId, types)
|
||||
}
|
||||
|
||||
const folderPathMap = new Map<string, string>()
|
||||
const folderById = new Map(folderRows.map((f) => [f.id, f]))
|
||||
function resolveFolderPath(id: string): string {
|
||||
const cached = folderPathMap.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
const folder = folderById.get(id)
|
||||
if (!folder) return id
|
||||
const parentPath = folder.parentId ? resolveFolderPath(folder.parentId) : ''
|
||||
const normalizedName = normalizeVfsSegment(folder.name)
|
||||
const path = parentPath ? `${parentPath}/${normalizedName}` : normalizedName
|
||||
folderPathMap.set(id, path)
|
||||
return path
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: wsRow,
|
||||
members,
|
||||
workflows: workflows.map((wf) => ({
|
||||
...wf,
|
||||
folderPath: wf.folderId ? resolveFolderPath(wf.folderId) : null,
|
||||
})),
|
||||
knowledgeBases: kbs.map((kb) => ({
|
||||
...kb,
|
||||
// Sort connector types so the snapshot is order-stable: the DB query has
|
||||
// no ORDER BY, and the Go delta engine compares item JSON byte-wise, so
|
||||
// an unsorted (but unchanged) list would emit a spurious "modified"
|
||||
// delta and needlessly bust the prompt cache.
|
||||
connectorTypes: connectorTypesByKb.get(kb.id)?.sort(stableCompare),
|
||||
})),
|
||||
tables: tables.map((t) => ({ id: t.id, name: t.name, description: t.description })),
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
folderPath: f.folderPath ?? null,
|
||||
})),
|
||||
oauthIntegrations: credentials.map((c) => ({
|
||||
id: c.id,
|
||||
providerId: c.providerId,
|
||||
displayName: c.displayName,
|
||||
role: c.role,
|
||||
})),
|
||||
envVariables: [],
|
||||
customTools: customTools.map((t) => ({ id: t.id, name: t.title })),
|
||||
customBlocks: customBlockSummaries,
|
||||
mcpServers: mcpServerRows,
|
||||
skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })),
|
||||
jobs: jobRows
|
||||
.filter((j) => j.status !== 'completed')
|
||||
.map((j) => ({
|
||||
id: j.id,
|
||||
title: j.jobTitle,
|
||||
prompt: j.prompt || '',
|
||||
cronExpression: j.cronExpression,
|
||||
status: j.status,
|
||||
lifecycle: j.lifecycle,
|
||||
sourceTaskName: j.sourceTaskName,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to build workspace data', {
|
||||
workspaceId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_CONTEXT_UNAVAILABLE_MD =
|
||||
'## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)'
|
||||
|
||||
/**
|
||||
* Generate WORKSPACE.md markdown from current DB state (primary db). The LLM
|
||||
* reads dynamic workspace state from VFS files; it never writes this file.
|
||||
*/
|
||||
export async function generateWorkspaceContext(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const data = await buildWorkspaceMdData(workspaceId, userId)
|
||||
return data ? buildWorkspaceMd(data) : WORKSPACE_CONTEXT_UNAVAILABLE_MD
|
||||
}
|
||||
|
||||
/**
|
||||
* Build BOTH the markdown inventory and the typed VFS snapshot from a single
|
||||
* primary-db fetch. The snapshot is the structured form Go diffs into
|
||||
* baseline+delta messages; the markdown is the transition fallback. Returns null
|
||||
* when the workspace is unavailable.
|
||||
*/
|
||||
export async function generateWorkspaceSnapshot(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> {
|
||||
const data = await buildWorkspaceMdData(workspaceId, userId)
|
||||
if (!data) return null
|
||||
return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the workspace inventory data to the typed VFS snapshot contract. Pure;
|
||||
* mirrors buildWorkspaceMd's field selection. Resource order is irrelevant — Go
|
||||
* diffs by stable id, not position.
|
||||
*/
|
||||
export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
|
||||
const workflows: VfsSnapshotV1Workflow[] = data.workflows.map((wf) => ({
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }),
|
||||
...(wf.description ? { description: wf.description } : {}),
|
||||
...(wf.isDeployed ? { isDeployed: true } : {}),
|
||||
...(wf.folderPath ? { folderPath: wf.folderPath } : {}),
|
||||
}))
|
||||
const jobs: VfsSnapshotV1Job[] = (data.jobs ?? [])
|
||||
.filter((j) => j.status !== 'completed')
|
||||
.map((j) => ({
|
||||
id: j.id,
|
||||
...(j.title ? { title: j.title } : {}),
|
||||
...(j.prompt ? { prompt: j.prompt } : {}),
|
||||
...(j.cronExpression ? { cronExpression: j.cronExpression } : {}),
|
||||
...(j.status ? { status: j.status } : {}),
|
||||
...(j.lifecycle ? { lifecycle: j.lifecycle } : {}),
|
||||
...(j.sourceTaskName ? { sourceTaskName: j.sourceTaskName } : {}),
|
||||
}))
|
||||
return {
|
||||
...(data.workspace
|
||||
? {
|
||||
workspace: {
|
||||
id: data.workspace.id,
|
||||
name: data.workspace.name,
|
||||
...(data.workspace.ownerId ? { ownerId: data.workspace.ownerId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
members: data.members.map((m) => ({
|
||||
...(m.name ? { name: m.name } : {}),
|
||||
email: m.email,
|
||||
...(m.permissionType ? { permissionType: m.permissionType } : {}),
|
||||
})),
|
||||
workflows,
|
||||
knowledgeBases: data.knowledgeBases.map((kb) => ({
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
...(kb.description ? { description: kb.description } : {}),
|
||||
...(kb.connectorTypes && kb.connectorTypes.length > 0
|
||||
? { connectorTypes: kb.connectorTypes }
|
||||
: {}),
|
||||
})),
|
||||
tables: data.tables.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
...(t.description ? { description: t.description } : {}),
|
||||
})),
|
||||
files: data.files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }),
|
||||
...(f.type ? { type: f.type } : {}),
|
||||
...(f.size ? { size: f.size } : {}),
|
||||
...(f.folderPath ? { folderPath: f.folderPath } : {}),
|
||||
})),
|
||||
integrations: data.oauthIntegrations.map((c) => ({
|
||||
id: c.id,
|
||||
providerId: c.providerId,
|
||||
...(c.displayName ? { displayName: c.displayName } : {}),
|
||||
...(c.role ? { role: c.role } : {}),
|
||||
})),
|
||||
envVars: data.envVariables,
|
||||
customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })),
|
||||
mcpServers: (data.mcpServers ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
...(s.url ? { url: s.url } : {}),
|
||||
...(s.enabled ? { enabled: true } : {}),
|
||||
})),
|
||||
skills: (data.skills ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
...(s.description ? { description: s.description } : {}),
|
||||
})),
|
||||
jobs,
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
Reference in New Issue
Block a user