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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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]
}
+165
View File
@@ -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()
})
})
+315
View File
@@ -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')
})
})
})
+122
View File
@@ -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))
}
+270
View File
@@ -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')
})
})
+403
View File
@@ -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
}
+449
View File
@@ -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()
})
})
+160
View File
@@ -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`
}