From 1a4518a668ed3bc9abd004e782951d583c9541e9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 16 Jun 2026 16:26:22 -0700 Subject: [PATCH 01/11] feat(subagents): add support for parallel subagents --- .../message-content/message-content.test.ts | 50 ++++ .../message-content/message-content.tsx | 10 + .../home/hooks/stream/handle-span-event.ts | 11 +- .../home/hooks/stream/stream-context.test.ts | 41 +++- .../home/hooks/stream/stream-context.ts | 59 +++-- .../lib/copilot/chat/effective-transcript.ts | 21 +- .../generated/mothership-stream-v1-schema.ts | 3 + .../copilot/generated/mothership-stream-v1.ts | 1 + .../request/context/request-context.ts | 4 +- .../copilot/request/context/result.test.ts | 1 + .../request/go/file-preview-adapter.ts | 155 ++++++------ .../sim/lib/copilot/request/go/stream.test.ts | 4 +- apps/sim/lib/copilot/request/go/stream.ts | 16 +- .../copilot/request/handlers/handlers.test.ts | 60 +++++ .../sim/lib/copilot/request/handlers/index.ts | 17 +- apps/sim/lib/copilot/request/handlers/run.ts | 3 + apps/sim/lib/copilot/request/handlers/span.ts | 12 +- apps/sim/lib/copilot/request/handlers/text.ts | 23 +- apps/sim/lib/copilot/request/handlers/tool.ts | 15 +- .../sim/lib/copilot/request/handlers/types.ts | 40 ++- apps/sim/lib/copilot/request/lifecycle/run.ts | 229 +++++++++++++++++- .../sim/lib/copilot/request/tools/executor.ts | 8 +- apps/sim/lib/copilot/request/types.ts | 52 +++- apps/sim/lib/copilot/tool-executor/types.ts | 7 + .../tools/registry/server-tool-adapter.ts | 1 + .../sim/lib/copilot/tools/server/base-tool.ts | 7 + .../tools/server/files/edit-content.ts | 6 + .../server/files/file-intent-store.test.ts | 122 ++++++++++ .../tools/server/files/file-intent-store.ts | 25 +- .../tools/server/files/workspace-file.ts | 3 + 30 files changed, 834 insertions(+), 172 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 02a9f247674..2f94a31e2cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -94,6 +94,56 @@ describe('parseBlocks span-identity tree', () => { expect(withContent[0].isDelegating).toBe(false) }) + it('keeps two concurrently-open subagent lanes separate with interleaved text', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'A', 'main'), + subagentStart('research', 'B', 'main'), + { type: 'subagent_text', content: 'A1 ', spanId: 'A', subagent: 'research', timestamp: 2 }, + { type: 'subagent_text', content: 'B1 ', spanId: 'B', subagent: 'research', timestamp: 2 }, + { type: 'subagent_text', content: 'A2', spanId: 'A', subagent: 'research', timestamp: 3 }, + ] + + const segments = parseBlocks(blocks) + const groups = segments.filter((s) => s.type === 'agent_group') + expect(groups).toHaveLength(2) + + const textOf = (g: (typeof groups)[number]): string => { + if (g.type !== 'agent_group') return '' + return g.items + .filter((i) => i.type === 'text') + .map((i) => (i.type === 'text' ? i.content : '')) + .join('') + } + // Group A (spanId A) created first, group B second. Interleaved chunks stay + // in their own lane and in order — no cross-contamination. + expect(textOf(groups[0])).toBe('A1 A2') + expect(textOf(groups[1])).toBe('B1 ') + }) + + it('renders a persisted subagent lane as closed when only endedAt is set (no subagent_end)', () => { + // The Sim backend stamps endedAt on the subagent block but does not emit a + // separate subagent_end block; a reloaded transcript must still show the + // lane closed (no stuck delegating spinner). + const blocks: ContentBlock[] = [ + { + type: 'subagent', + content: 'research', + spanId: 'S1', + parentSpanId: 'main', + timestamp: 1, + endedAt: 5, + }, + { type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'research', timestamp: 2 }, + ] + + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + expect(group).toBeDefined() + if (!group || group.type !== 'agent_group') throw new Error('expected research group') + expect(group.isOpen).toBe(false) + expect(group.isDelegating).toBe(false) + }) + it('prunes an empty nested subagent that started and ended without output', () => { const blocks: ContentBlock[] = [ subagentStart('workflow', 'S1', 'main'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 05cc1544389..4efb6eeb5ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -315,6 +315,16 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content] if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId, i) + if (block.endedAt !== undefined) { + // Persisted backend path: the lane was stamped closed (endedAt) without + // a separate subagent_end block (the Sim backend stamps endedAt only; + // only the live browser path pushes subagent_end). Honor endedAt so a + // reloaded transcript shows the subagent closed instead of a stuck + // delegating spinner. + g.isOpen = false + g.isDelegating = false + continue + } // Show the working/delegating spinner from span open until the agent // emits its first content or tool (or ends). The legacy path derived this // from the dispatch tool_call, which the span path absorbs, so we set it diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts index 49db0877be5..78aca651bcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts @@ -106,11 +106,12 @@ export function handleSpanEvent( deps.setActiveResourceId(lastFileResource.id) } } - if ( - !parentToolCallId || - parentToolCallId === state.activeSubagentParentToolCallId || - name === state.activeSubagent - ) { + // Clear the legacy single pointer only when THIS ending lane is the active + // one (matched by parent tool call id, or an unscoped end). Never clear by + // agent name alone — a concurrent same-name subagent that is still open must + // not be torn down by a sibling's end. Per-lane state lives in the + // subagentBySpanId / subagentByParentToolCallId maps cleared above. + if (!parentToolCallId || parentToolCallId === state.activeSubagentParentToolCallId) { state.activeSubagent = undefined state.activeSubagentParentToolCallId = undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts index 4d616426047..4d810f4fe60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts @@ -223,7 +223,23 @@ describe('createStreamLoopContext', () => { expect(Number.isFinite(ms)).toBe(true) }) - it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId, then active', () => { + it('stampBlockEnd never closes a subagent header (prevents concurrent-lane flicker)', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + + // A subagent header must stay open when a generic block boundary fires + // (e.g. the next sibling subagent starts, or this lane's first content + // arrives). endedAt is set only by the real span-end handler. + const header: ContentBlock = { type: 'subagent', content: 'research', spanId: 's1' } + ctx.ops.stampBlockEnd(header) + expect(header.endedAt).toBeUndefined() + + // Other block types still get their endedAt stamped as before. + const text: ContentBlock = { type: 'text', content: 'hi' } + ctx.ops.stampBlockEnd(text) + expect(text.endedAt).toBeTypeOf('number') + }) + + it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId (scope-only, no active fallback)', () => { const ctx = createStreamLoopContext(makeStreamLoopDeps()) ctx.state.subagentBySpanId.set('s1', 'spanAgent') ctx.state.subagentByParentToolCallId.set('p1', 'parentAgent') @@ -231,7 +247,28 @@ describe('createStreamLoopContext', () => { expect(ctx.ops.resolveScopedSubagent('explicit', 'p1', 's1')).toBe('explicit') expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', 's1')).toBe('spanAgent') expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', undefined)).toBe('parentAgent') - expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBe('activeAgent') + // No scope match → undefined (the legacy activeSubagent fallback was + // removed so a concurrent sibling can never be mis-attributed). + expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBeUndefined() + }) + + it('rebuilds every open subagent lane on reconnect, skipping closed ones', () => { + const blocks: ContentBlock[] = [ + { type: 'subagent', content: 'research', spanId: 'span-a', parentToolCallId: 'tc-a' }, + { type: 'subagent', content: 'deploy', spanId: 'span-b', parentToolCallId: 'tc-b' }, + // span-b closed via marker; span-a stays open. + { type: 'subagent_end', spanId: 'span-b', parentToolCallId: 'tc-b' }, + ] + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + options: { preserveExistingState: true }, + streamingBlocksRef: ref(blocks), + }) + ) + expect(ctx.state.subagentBySpanId.get('span-a')).toBe('research') + expect(ctx.state.subagentBySpanId.has('span-b')).toBe(false) + expect(ctx.state.subagentByParentToolCallId.get('tc-a')).toBe('research') + expect(ctx.state.subagentByParentToolCallId.has('tc-b')).toBe(false) }) it('buildInlineErrorTag includes the message, code and provider', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 07d998bfe20..64b0daa654c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -218,20 +218,31 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext if (tc.params) state.toolArgsMap.set(tc.id, tc.params) } } + // Rebuild ALL open subagent lanes (not just the most recent one) so that a + // reconnect mid-flight with multiple concurrent subagents rehydrates every + // lane. A lane is closed when its `subagent` start block has an endedAt OR a + // matching `subagent_end` marker exists (the live path stamps endedAt and + // pushes subagent_end; the persisted backend path stamps endedAt only). + const endedSpanIds = new Set() + const endedParents = new Set() for (const block of state.blocks) { - if (block.type === 'subagent' && block.spanId && block.content) { - state.subagentBySpanId.set(block.spanId, block.content) + if (block.type === 'subagent_end') { + if (block.spanId) endedSpanIds.add(block.spanId) + if (block.parentToolCallId) endedParents.add(block.parentToolCallId) } } - for (let i = state.blocks.length - 1; i >= 0; i--) { - if (state.blocks[i].type === 'subagent' && state.blocks[i].content) { - state.activeSubagent = state.blocks[i].content - state.activeSubagentParentToolCallId = state.blocks[i].parentToolCallId - break - } - if (state.blocks[i].type === 'subagent_end') { - break + for (const block of state.blocks) { + if (block.type !== 'subagent' || !block.content || block.endedAt !== undefined) continue + if (block.spanId && endedSpanIds.has(block.spanId)) continue + if (block.parentToolCallId && endedParents.has(block.parentToolCallId)) continue + if (block.spanId) state.subagentBySpanId.set(block.spanId, block.content) + if (block.parentToolCallId) { + state.subagentByParentToolCallId.set(block.parentToolCallId, block.content) } + // Keep a best-effort single pointer for legacy (no-spanId) dedup only; + // routing no longer depends on it. + state.activeSubagent = block.content + state.activeSubagentParentToolCallId = block.parentToolCallId } } else if (!isStale()) { deps.streamingContentRef.current = '' @@ -247,7 +258,15 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext } const stampBlockEnd = (block: ContentBlock | undefined, ts?: string) => { - if (block && block.endedAt === undefined) block.endedAt = toEventMs(ts) + // Never stamp a subagent header here. Its endedAt is the renderer's + // "group closed" signal (parseBlocksWithSpanTree), set explicitly only when + // the subagent's span actually ends (the span-end handler and the backend + // both set it directly). Stamping it as a generic block boundary — when the + // next sibling subagent starts, or when this lane's first content arrives — + // would close + prune concurrent subagents mid-stream, making them all flash + // in, vanish to one, then reappear one-by-one as content trickles in. + if (!block || block.type === 'subagent') return + if (block.endedAt === undefined) block.endedAt = toEventMs(ts) } const ensureTextBlock = ( @@ -306,6 +325,12 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext parentToolCallId: string | undefined, spanId?: string ): string | undefined => { + // Scope-only: resolve by the event's own identity. The legacy + // `state.activeSubagent` fallback was removed — with concurrent subagents it + // points at whichever started most recently and would mis-attribute an + // interleaved event from a different lane. Well-formed subagent events carry + // agentId (and spanId), so this resolves deterministically; anything else is + // treated as main-lane rather than guessed. if (agentId) return agentId if (spanId) { const scoped = state.subagentBySpanId.get(spanId) @@ -315,20 +340,18 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext const scoped = state.subagentByParentToolCallId.get(parentToolCallId) if (scoped) return scoped } - return state.activeSubagent + return undefined } const resolveParentForSubagentBlock = ( subagent: string | undefined, scopedParent: string | undefined ): string | undefined => { + // Scope-only: a subagent block's parent comes from the event's own scope. + // The previous "first parent whose name matches" scan was ambiguous when two + // concurrent subagents share an agent name, so it was removed. if (!subagent) return undefined - if (scopedParent) return scopedParent - if (state.activeSubagent === subagent) return state.activeSubagentParentToolCallId - for (const [parent, name] of state.subagentByParentToolCallId) { - if (name === subagent) return parent - } - return undefined + return scopedParent } const flush = () => { diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index cd5f432b142..f615448a356 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -119,6 +119,9 @@ function buildLiveAssistantMessage(params: { 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, @@ -133,7 +136,7 @@ function buildLiveAssistantMessage(params: { const scoped = subagentByParentToolCallId.get(parentToolCallId) if (scoped) return scoped } - return activeSubagent + return undefined } const resolveParentForSubagentBlock = ( @@ -141,12 +144,7 @@ function buildLiveAssistantMessage(params: { scopedParent: string | undefined ): string | undefined => { if (!subagent) return undefined - if (scopedParent) return scopedParent - if (activeSubagent === subagent) return activeSubagentParentToolCallId - for (const [parent, name] of subagentByParentToolCallId) { - if (name === subagent) return parent - } - return undefined + return scopedParent } const ensureToolBlock = (input: { @@ -364,11 +362,10 @@ function buildLiveAssistantMessage(params: { if (parentToolCallId) { subagentByParentToolCallId.delete(parentToolCallId) } - if ( - !parentToolCallId || - parentToolCallId === activeSubagentParentToolCallId || - name === activeSubagent - ) { + // 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 } diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 88e8344e560..91059cbbd2f 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -50,6 +50,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { MothershipStreamV1CheckpointPauseFrame: { additionalProperties: false, properties: { + checkpointId: { + type: 'string', + }, parentToolCallId: { type: 'string', }, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index d7194f13757..32ca1d88d51 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -319,6 +319,7 @@ export interface MothershipStreamV1CheckpointPausePayload { runId: string } export interface MothershipStreamV1CheckpointPauseFrame { + checkpointId?: string parentToolCallId: string parentToolName: string pendingToolIds: string[] diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 9ee241ba183..2f3a76ba5eb 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -18,7 +18,7 @@ export function createStreamingContext(overrides?: Partial): S toolCalls: new Map(), pendingToolPromises: new Map(), currentThinkingBlock: null, - currentSubagentThinkingBlock: null, + subagentThinkingBlocks: new Map(), isInThinkingBlock: false, subAgentParentToolCallId: undefined, subAgentParentStack: [], @@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial): S streamComplete: false, wasAborted: false, errors: [], - activeFileIntent: null, + activeFileIntents: new Map(), trace: new TraceCollector(), ...overrides, } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 1cbbd5a5103..437ff36ee86 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -23,6 +23,7 @@ function makeContext(): StreamingContext { pendingToolPromises: new Map(), awaitingAsyncContinuation: undefined, currentThinkingBlock: null, + subagentThinkingBlocks: new Map(), isInThinkingBlock: false, subAgentParentToolCallId: undefined, subAgentParentStack: [], diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 46c40413c01..581dd8c8f93 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -13,15 +13,13 @@ import { upsertFilePreviewSession, } from '@/lib/copilot/request/session' import type { + ActiveFileIntent, ExecutionContext, OrchestratorOptions, StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' -import { - clearIntentsForWorkspace, - peekFileIntent, -} from '@/lib/copilot/tools/server/files/file-intent-store' +import { peekFileIntent } from '@/lib/copilot/tools/server/files/file-intent-store' import { buildFilePreviewText, loadWorkspaceFileTextForPreview, @@ -31,7 +29,7 @@ import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/ const logger = createLogger('CopilotFilePreviewAdapter') type JsonRecord = Record -type FileIntent = NonNullable +type FileIntent = ActiveFileIntent type EditContentStreamState = { raw: string @@ -348,6 +346,19 @@ export async function processFilePreviewStreamEvent(input: { const { streamId, streamEvent, context, execContext, options, state } = input const { editContentState, filePreviewState } = state + // Scope the in-flight intent to the invoking file subagent's channel (its + // outer tool_use id) so two file agents streaming concurrently never read or + // overwrite each other's intent. workspace_file and edit_content from the same + // file agent share this channel id, so they pair up; siblings stay isolated. + const channelId = streamEvent.scope?.parentToolCallId ?? '' + const getIntent = (): FileIntent | null => context.activeFileIntents.get(channelId) ?? null + const setIntent = (intent: FileIntent): void => { + context.activeFileIntents.set(channelId, intent) + } + const clearIntent = (): void => { + context.activeFileIntents.delete(channelId) + } + if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file') { const toolCallId = streamEvent.payload.toolCallId const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments) @@ -361,31 +372,10 @@ export async function processFilePreviewStreamEvent(input: { const { fileId, fileName } = target const isContentOp = isContentOperation(operation) - if (context.activeFileIntent && isContentOp) { - logger.warn( - 'Orphaned workspace_file intent: content-op workspace_file arrived without edit_content for prior intent', - { - orphanedToolCallId: context.activeFileIntent.toolCallId, - orphanedOperation: context.activeFileIntent.operation, - newToolCallId: toolCallId, - newOperation: operation, - } - ) - if (execContext.workspaceId) { - const cleared = await clearIntentsForWorkspace(execContext.workspaceId, { - chatId: execContext.chatId, - messageId: execContext.messageId, - }) - if (cleared > 0) { - logger.warn('Cleared orphaned execution intents from store', { - cleared, - workspaceId: execContext.workspaceId, - }) - } - } - } - - context.activeFileIntent = { + // Per-channel: a re-declared workspace_file just overwrites THIS channel's + // slot. No cross-message intent clearing — that would wipe a concurrent + // sibling file agent's pending intent. + const intent: FileIntent = { toolCallId, operation, target, @@ -393,6 +383,7 @@ export async function processFilePreviewStreamEvent(input: { ...(contentType ? { contentType } : {}), ...(edit ? { edit } : {}), } + setIntent(intent) if (isContentOp && previewTargetKind) { let previewBaseContent: string | undefined @@ -407,7 +398,7 @@ export async function processFilePreviewStreamEvent(input: { ) } - let session = buildPreviewSessionFromIntent(streamId, context.activeFileIntent) + let session = buildPreviewSessionFromIntent(streamId, intent) if (previewBaseContent !== undefined) { session = { ...session, baseContent: previewBaseContent } } @@ -447,29 +438,30 @@ export async function processFilePreviewStreamEvent(input: { } } + const workspaceResultIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file' && - context.activeFileIntent && - isContentOperation(context.activeFileIntent.operation) + workspaceResultIntent && + isContentOperation(workspaceResultIntent.operation) ) { const result = extractWorkspaceFileResult(streamEvent.payload.output) - if (result.fileId && context.activeFileIntent.target.kind === 'path') { - context.activeFileIntent = { - ...context.activeFileIntent, + if (result.fileId && workspaceResultIntent.target.kind === 'path') { + const intent: FileIntent = { + ...workspaceResultIntent, target: { kind: 'file_id', fileId: result.fileId, - fileName: result.fileName ?? context.activeFileIntent.target.fileName, - path: context.activeFileIntent.target.path, + fileName: result.fileName ?? workspaceResultIntent.target.fileName, + path: workspaceResultIntent.target.path, }, } + setIntent(intent) let previewBaseContent: string | undefined if ( execContext.workspaceId && - (context.activeFileIntent.operation === 'append' || - context.activeFileIntent.operation === 'patch') + (intent.operation === 'append' || intent.operation === 'patch') ) { previewBaseContent = await loadWorkspaceFileTextForPreview( execContext.workspaceId, @@ -477,11 +469,11 @@ export async function processFilePreviewStreamEvent(input: { ) } - let session = buildPreviewSessionFromIntent(streamId, context.activeFileIntent) + let session = buildPreviewSessionFromIntent(streamId, intent) if (previewBaseContent !== undefined) { session = { ...session, baseContent: previewBaseContent } } - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(intent.toolCallId, { session, lastEmittedPreviewText: '', lastSnapshotAt: 0, @@ -489,46 +481,47 @@ export async function processFilePreviewStreamEvent(input: { await persistFilePreviewSession(session) await emitPreviewEvent(streamEvent, options, { - toolCallId: context.activeFileIntent.toolCallId, + toolCallId: intent.toolCallId, toolName: 'workspace_file', previewPhase: 'file_preview_start', }) await emitPreviewEvent(streamEvent, options, { - toolCallId: context.activeFileIntent.toolCallId, + toolCallId: intent.toolCallId, toolName: 'workspace_file', previewPhase: 'file_preview_target', - operation: context.activeFileIntent.operation, + operation: intent.operation, target: { kind: 'file_id', fileId: result.fileId, ...(result.fileName ? { fileName: result.fileName } : {}), }, - ...(context.activeFileIntent.title ? { title: context.activeFileIntent.title } : {}), + ...(intent.title ? { title: intent.title } : {}), }) - if (context.activeFileIntent.edit) { + if (intent.edit) { await emitPreviewEvent(streamEvent, options, { - toolCallId: context.activeFileIntent.toolCallId, + toolCallId: intent.toolCallId, toolName: 'workspace_file', previewPhase: 'file_preview_edit_meta', - edit: context.activeFileIntent.edit, + edit: intent.edit, }) } } } + const patchDeleteIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file' && - context.activeFileIntent && - isContentOperation(context.activeFileIntent.operation) && - context.activeFileIntent.operation === 'patch' && - context.activeFileIntent.edit?.strategy === 'anchored' && - context.activeFileIntent.edit?.mode === 'delete_between' && + patchDeleteIntent && + isContentOperation(patchDeleteIntent.operation) && + patchDeleteIntent.operation === 'patch' && + patchDeleteIntent.edit?.strategy === 'anchored' && + patchDeleteIntent.edit?.mode === 'delete_between' && execContext.workspaceId && - context.activeFileIntent.target.fileId && - !isDocFormat(context.activeFileIntent.target.fileName) + patchDeleteIntent.target.fileId && + !isDocFormat(patchDeleteIntent.target.fileName) ) { - const currentPreview = filePreviewState.get(context.activeFileIntent.toolCallId) + const currentPreview = filePreviewState.get(patchDeleteIntent.toolCallId) const previewText = buildFilePreviewText({ operation: 'patch', streamedContent: '', @@ -539,7 +532,7 @@ export async function processFilePreviewStreamEvent(input: { if (previewText !== undefined) { const baseSession = buildPreviewSessionFromIntent( streamId, - context.activeFileIntent, + patchDeleteIntent, currentPreview?.session ) const nextSession: FilePreviewSession = { @@ -549,7 +542,7 @@ export async function processFilePreviewStreamEvent(input: { previewVersion: (currentPreview?.session.previewVersion ?? 0) + 1, updatedAt: new Date().toISOString(), } - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(patchDeleteIntent.toolCallId, { session: nextSession, lastEmittedPreviewText: previewText, lastSnapshotAt: Date.now(), @@ -577,29 +570,30 @@ export async function processFilePreviewStreamEvent(input: { const stateForTool = editContentState.get(toolCallId) ?? { raw: '' } stateForTool.raw += delta - if (context.activeFileIntent) { + const editIntent = getIntent() + if (editIntent) { const streamedContent = extractEditContent(stateForTool.raw) if (streamedContent !== (stateForTool.lastContentSnapshot ?? '')) { stateForTool.lastContentSnapshot = streamedContent - let currentPreview = filePreviewState.get(context.activeFileIntent.toolCallId) ?? { - session: buildPreviewSessionFromIntent(streamId, context.activeFileIntent), + let currentPreview = filePreviewState.get(editIntent.toolCallId) ?? { + session: buildPreviewSessionFromIntent(streamId, editIntent), lastEmittedPreviewText: '', lastSnapshotAt: 0, } if ( currentPreview.session.baseContent === undefined && - (context.activeFileIntent.operation === 'append' || - context.activeFileIntent.operation === 'patch') && + (editIntent.operation === 'append' || editIntent.operation === 'patch') && execContext.workspaceId && - context.activeFileIntent.target.fileId + editIntent.target.fileId ) { const intentBase = await peekFileIntent( execContext.workspaceId, - context.activeFileIntent.target.fileId, + editIntent.target.fileId, { chatId: execContext.chatId, messageId: execContext.messageId, + channelId, } ) if (typeof intentBase?.existingContent === 'string') { @@ -612,14 +606,14 @@ export async function processFilePreviewStreamEvent(input: { ...currentPreview, session: seededSession, } - filePreviewState.set(context.activeFileIntent.toolCallId, currentPreview) + filePreviewState.set(editIntent.toolCallId, currentPreview) await persistFilePreviewSession(seededSession) } } - const previewText = isContentOperation(context.activeFileIntent.operation) + const previewText = isContentOperation(editIntent.operation) ? buildFilePreviewText({ - operation: context.activeFileIntent.operation, + operation: editIntent.operation, streamedContent, existingContent: currentPreview.session.baseContent, edit: currentPreview.session.edit, @@ -629,7 +623,7 @@ export async function processFilePreviewStreamEvent(input: { if (previewText !== undefined) { const baseSession = buildPreviewSessionFromIntent( streamId, - context.activeFileIntent, + editIntent, currentPreview.session ) const now = Date.now() @@ -647,7 +641,7 @@ export async function processFilePreviewStreamEvent(input: { nextSession.operation === 'patch' && now - currentPreview.lastSnapshotAt < PATCH_PREVIEW_SNAPSHOT_INTERVAL_MS ) { - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(editIntent.toolCallId, { session: nextSession, lastEmittedPreviewText: currentPreview.lastEmittedPreviewText, lastSnapshotAt: currentPreview.lastSnapshotAt, @@ -661,7 +655,7 @@ export async function processFilePreviewStreamEvent(input: { nextSession.operation ) - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(editIntent.toolCallId, { session: nextSession, lastEmittedPreviewText: nextSession.previewText, lastSnapshotAt: previewUpdate.lastSnapshotAt, @@ -682,7 +676,7 @@ export async function processFilePreviewStreamEvent(input: { }) } } else { - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(editIntent.toolCallId, { session: currentPreview.session, lastEmittedPreviewText: currentPreview.lastEmittedPreviewText, lastSnapshotAt: currentPreview.lastSnapshotAt, @@ -701,12 +695,13 @@ export async function processFilePreviewStreamEvent(input: { } } + const editResultIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content' && - context.activeFileIntent + editResultIntent ) { - const currentPreview = filePreviewState.get(context.activeFileIntent.toolCallId) + const currentPreview = filePreviewState.get(editResultIntent.toolCallId) const completedAt = new Date().toISOString() if ( @@ -714,7 +709,7 @@ export async function processFilePreviewStreamEvent(input: { currentPreview.lastEmittedPreviewText !== currentPreview.session.previewText && currentPreview.session.previewText.length > 0 ) { - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(editResultIntent.toolCallId, { session: currentPreview.session, lastEmittedPreviewText: currentPreview.session.previewText, lastSnapshotAt: Date.now(), @@ -745,7 +740,7 @@ export async function processFilePreviewStreamEvent(input: { updatedAt: completedAt, completedAt, } - filePreviewState.set(context.activeFileIntent.toolCallId, { + filePreviewState.set(editResultIntent.toolCallId, { session: completedSession, lastEmittedPreviewText: completedSession.previewText, lastSnapshotAt: Date.now(), @@ -754,13 +749,13 @@ export async function processFilePreviewStreamEvent(input: { } await emitPreviewEvent(streamEvent, options, { - toolCallId: context.activeFileIntent.toolCallId, + toolCallId: editResultIntent.toolCallId, toolName: 'workspace_file', previewPhase: 'file_preview_complete', - fileId: context.activeFileIntent.target.fileId, + fileId: editResultIntent.target.fileId, output: streamEvent.payload.output, ...(currentPreview ? { previewVersion: currentPreview.session.previewVersion } : {}), }) - context.activeFileIntent = null + clearIntent() } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index a152fad1b9d..bb070282f58 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -94,7 +94,7 @@ function createStreamingContext(): StreamingContext { toolCalls: new Map(), pendingToolPromises: new Map(), currentThinkingBlock: null, - currentSubagentThinkingBlock: null, + subagentThinkingBlocks: new Map(), isInThinkingBlock: false, subAgentParentToolCallId: undefined, subAgentParentStack: [], @@ -104,7 +104,7 @@ function createStreamingContext(): StreamingContext { streamComplete: false, wasAborted: false, errors: [], - activeFileIntent: null, + activeFileIntents: new Map(), trace: new TraceCollector(), } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 362da84bb08..b4ae93d48d1 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -456,10 +456,18 @@ export async function runStreamLoop( } } - if (handleSubagentRouting(streamEvent, context)) { - const handler = subAgentHandlers[streamEvent.type] - if (handler) { - await handler(streamEvent, context, execContext, options) + // Subagent-lane events are routed ONLY by their own scope. A valid one + // (has parentToolCallId) goes to the subagent handler; a malformed one + // (missing parentToolCallId — Go always stamps it, so this is defensive) + // is DROPPED rather than falling through to the main handler, which would + // merge foreign subagent text/tools into the durable main assistant + // message and mis-attribute it. + if (streamEvent.scope?.lane === 'subagent') { + if (handleSubagentRouting(streamEvent, context)) { + const handler = subAgentHandlers[streamEvent.type] + if (handler) { + await handler(streamEvent, context, execContext, options) + } } return context.streamComplete || undefined } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index d55fe63bbaa..25d445e81b0 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -91,6 +91,7 @@ describe('sse-handlers tool lifecycle', () => { toolCalls: new Map(), pendingToolPromises: new Map(), currentThinkingBlock: null, + subagentThinkingBlocks: new Map(), isInThinkingBlock: false, subAgentParentToolCallId: undefined, subAgentParentStack: [], @@ -603,6 +604,65 @@ describe('sse-handlers tool lifecycle', () => { expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1') }) + it('keeps two concurrent subagent lanes separate for text and thinking', async () => { + const send = (parent: string, channel: MothershipStreamV1TextChannel, text: string) => + subAgentHandlers.text( + { + type: MothershipStreamV1EventType.text, + scope: { + lane: 'subagent', + parentToolCallId: parent, + spanId: `span-${parent}`, + agentId: 'research', + }, + payload: { channel, text }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + // Interleaved thinking across two concurrent lanes. + await send('A', MothershipStreamV1TextChannel.thinking, 'A-think-1 ') + await send('B', MothershipStreamV1TextChannel.thinking, 'B-think-1 ') + await send('A', MothershipStreamV1TextChannel.thinking, 'A-think-2') + + // Each lane accumulates its own thinking block — no cross-contamination. + expect(context.subagentThinkingBlocks.get('A')?.content).toBe('A-think-1 A-think-2') + expect(context.subagentThinkingBlocks.get('B')?.content).toBe('B-think-1 ') + + // Interleaved assistant text across the two lanes. + await send('A', MothershipStreamV1TextChannel.assistant, 'A-text') + await send('B', MothershipStreamV1TextChannel.assistant, 'B-text') + + expect(context.subAgentContent.A).toBe('A-text') + expect(context.subAgentContent.B).toBe('B-text') + + // Assistant text flushed each lane's thinking into contentBlocks, attributed + // to the correct parent (not whichever subagent streamed most recently). + const thinking = context.contentBlocks.filter((b) => b.type === 'subagent_thinking') + expect(thinking.find((b) => b.parentToolCallId === 'A')?.content).toBe('A-think-1 A-think-2') + expect(thinking.find((b) => b.parentToolCallId === 'B')?.content).toBe('B-think-1 ') + }) + + it('drops a subagent text event that is missing its parent tool call id', async () => { + const before = context.contentBlocks.length + await subAgentHandlers.text( + { + type: MothershipStreamV1EventType.text, + scope: { lane: 'subagent', agentId: 'research' }, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'orphan' }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + // No lane to attribute to — nothing is added rather than mis-attributed. + expect(context.contentBlocks.length).toBe(before) + expect(Object.keys(context.subAgentContent)).not.toContain('undefined') + }) + it('skips duplicate tool_call after result', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) diff --git a/apps/sim/lib/copilot/request/handlers/index.ts b/apps/sim/lib/copilot/request/handlers/index.ts index 8231775914a..b170f9104b8 100644 --- a/apps/sim/lib/copilot/request/handlers/index.ts +++ b/apps/sim/lib/copilot/request/handlers/index.ts @@ -33,17 +33,16 @@ export const subAgentHandlers: Record = { [MothershipStreamV1EventType.span]: handleSpanEvent, } -export function handleSubagentRouting(event: StreamEvent, context: StreamingContext): boolean { +export function handleSubagentRouting(event: StreamEvent, _context: StreamingContext): boolean { if (event.scope?.lane !== 'subagent') return false - // Keep the latest scoped parent on hand for legacy callers, but subagent - // handlers should prefer the event-local scope for correctness. - if (event.scope?.parentToolCallId) { - context.subAgentParentToolCallId = event.scope.parentToolCallId - } - - if (!context.subAgentParentToolCallId) { - logger.warn('Subagent event missing parent tool call', { + // Scope-only attribution: a subagent event MUST carry its own parentToolCallId. + // With concurrent subagents there is no single "current" lane to fall back to — + // routing by a global pointer would mis-attribute interleaved events to the + // last-started subagent. A missing parentToolCallId is a contract violation + // (Go always stamps it), so warn and route to the main lane rather than guess. + if (!event.scope?.parentToolCallId) { + logger.warn('Subagent event missing parent tool call id; routing to main lane', { type: event.type, subagent: event.scope?.agentId, }) diff --git a/apps/sim/lib/copilot/request/handlers/run.ts b/apps/sim/lib/copilot/request/handlers/run.ts index 830d935e4cc..593eecce536 100644 --- a/apps/sim/lib/copilot/request/handlers/run.ts +++ b/apps/sim/lib/copilot/request/handlers/run.ts @@ -18,6 +18,9 @@ export const handleRunEvent: StreamHandler = (event, context) => { parentToolCallId: frame.parentToolCallId, parentToolName: frame.parentToolName, pendingToolIds: frame.pendingToolIds, + // Carried through for the per-subagent resume fan-out; undefined under the + // legacy bundled-frame model (all frames share the top-level checkpointId). + ...(frame.checkpointId ? { checkpointId: frame.checkpointId } : {}), })) context.awaitingAsyncContinuation = { diff --git a/apps/sim/lib/copilot/request/handlers/span.ts b/apps/sim/lib/copilot/request/handlers/span.ts index 978e6ec0780..2ad6dcf3382 100644 --- a/apps/sim/lib/copilot/request/handlers/span.ts +++ b/apps/sim/lib/copilot/request/handlers/span.ts @@ -30,19 +30,23 @@ export const handleSpanEvent: StreamHandler = (event, context) => { if (kind === MothershipStreamV1SpanPayloadKind.subagent) { const scopeAgent = typeof payload.agent === 'string' && payload.agent ? payload.agent : 'subagent' + // Key by the deterministic spanId so two concurrent runs of the SAME agent + // (e.g. two parallel `research` subagents) get distinct trace spans. Fall + // back to agent:parentToolCallId for legacy events that predate span ids. + const traceKey = event.scope?.spanId || `${scopeAgent}:${event.scope?.parentToolCallId || ''}` if (evt === MothershipStreamV1SpanLifecycleEvent.start) { const span = context.trace.startSpan(`subagent:${scopeAgent}`, 'go.subagent', { agent: scopeAgent, parentToolCallId: event.scope?.parentToolCallId, + spanId: event.scope?.spanId, }) context.subAgentTraceSpans ??= new Map() - context.subAgentTraceSpans.set(`${scopeAgent}:${event.scope?.parentToolCallId || ''}`, span) + context.subAgentTraceSpans.set(traceKey, span) } else if (evt === MothershipStreamV1SpanLifecycleEvent.end) { - const key = `${scopeAgent}:${event.scope?.parentToolCallId || ''}` - const span = context.subAgentTraceSpans?.get(key) + const span = context.subAgentTraceSpans?.get(traceKey) if (span) { context.trace.endSpan(span, 'ok') - context.subAgentTraceSpans?.delete(key) + context.subAgentTraceSpans?.delete(traceKey) } } return diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 9ad195f4977..16fbc9b6ead 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -24,27 +24,26 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (!parentToolCallId) return const spanIdentity = getScopedSpanIdentity(event) if (event.payload.channel === MothershipStreamV1TextChannel.thinking) { - if ( - context.currentSubagentThinkingBlock && - context.currentSubagentThinkingBlock.parentToolCallId !== parentToolCallId - ) { - flushSubagentThinkingBlock(context) - } - if (!context.currentSubagentThinkingBlock) { - context.currentSubagentThinkingBlock = { + // Per-lane thinking: each concurrent subagent accumulates into its own + // block keyed by parentToolCallId, so interleaved chunks from a sibling + // subagent never flush or corrupt this lane's reasoning. + let block = context.subagentThinkingBlocks.get(parentToolCallId) + if (!block) { + block = { type: 'subagent_thinking', content: '', parentToolCallId, ...spanIdentity, timestamp: Date.now(), } + context.subagentThinkingBlocks.set(parentToolCallId, block) } - context.currentSubagentThinkingBlock.content = `${context.currentSubagentThinkingBlock.content || ''}${chunk}` + block.content = `${block.content || ''}${chunk}` return } - if (context.currentSubagentThinkingBlock) { - flushSubagentThinkingBlock(context) - } + // Real text for this lane: close this lane's thinking block first so the + // persisted order is [thinking, text] within the lane. + flushSubagentThinkingBlock(context, parentToolCallId) if (context.isInThinkingBlock) { flushThinkingBlock(context) } diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 0811917fd1f..7e76a57b2f3 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -138,8 +138,14 @@ export async function handleToolEvent( // block into contentBlocks BEFORE we add the tool_call block, or // contentBlocks will end up with tool_call before thinking — which // re-renders on reload in the wrong order (Mothership group above - // the Thinking block, even though thinking happened first). - flushSubagentThinkingBlock(context) + // the Thinking block, even though thinking happened first). A subagent + // tool event flushes only its OWN lane so a concurrent sibling's thinking + // is left intact; a main tool event flushes all subagent lanes. + if (isSubagent && parentToolCallId) { + flushSubagentThinkingBlock(context, parentToolCallId) + } else { + flushSubagentThinkingBlock(context) + } flushThinkingBlock(context) if (isToolResultStreamEvent(event)) { @@ -294,6 +300,11 @@ async function handleCallPhase( const toolCall = context.toolCalls.get(toolCallId) if (!toolCall) return + // Capture the invoking subagent's channel id so the executor can thread it + // into the server tool context — this is what scopes the workspace_file -> + // edit_content intent handoff to one file subagent under concurrency. + if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId + const readPath = typeof args?.path === 'string' ? args.path : undefined if (toolName === 'read' && readPath?.startsWith('internal/')) return diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index 9f41a996adf..21543ce5d60 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -65,19 +65,45 @@ export function flushThinkingBlock(context: StreamingContext): void { context.currentThinkingBlock = null } -export function flushSubagentThinkingBlock(context: StreamingContext): void { - if (context.currentSubagentThinkingBlock) { - stampBlockEnd(context.currentSubagentThinkingBlock) - context.contentBlocks.push(context.currentSubagentThinkingBlock) +/** + * Flush open subagent thinking blocks into contentBlocks. With a parentToolCallId + * it flushes only that lane (used when a tool/text event arrives for a specific + * subagent); with no argument it flushes ALL open lanes (used at stream end and + * at subagent lifecycle boundaries). Safe to call repeatedly. + */ +export function flushSubagentThinkingBlock( + context: StreamingContext, + parentToolCallId?: string +): void { + if (parentToolCallId !== undefined) { + const block = context.subagentThinkingBlocks.get(parentToolCallId) + if (block) { + stampBlockEnd(block) + context.contentBlocks.push(block) + context.subagentThinkingBlocks.delete(parentToolCallId) + } + return + } + for (const block of context.subagentThinkingBlocks.values()) { + stampBlockEnd(block) + context.contentBlocks.push(block) } - context.currentSubagentThinkingBlock = null + context.subagentThinkingBlocks.clear() } +/** + * Resolve the subagent lane an event belongs to, using ONLY the event's own + * scope. The legacy fallback to a single "current subagent" pointer was removed: + * with concurrent subagents that pointer reflects whichever subagent started + * most recently and would mis-attribute interleaved events. Every subagent-lane + * event is guaranteed to carry parentToolCallId (Go stamps it), so a missing one + * is a real contract violation — callers warn and drop rather than guess. + */ export function getScopedParentToolCallId( event: StreamEvent, - context: StreamingContext + _context: StreamingContext ): string | undefined { - return event.scope?.parentToolCallId || context.subAgentParentToolCallId + return event.scope?.parentToolCallId } /** diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 01c597c6357..1f9e38856d1 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -231,6 +231,204 @@ export async function runCopilotLifecycle( } } +// --------------------------------------------------------------------------- +// Per-subagent checkpoint resume (concurrent fan-out) +// --------------------------------------------------------------------------- +// +// Under the subagent-checkpoints model each paused subagent is its OWN checkpoint +// chain (frame.checkpointId) joined at the orchestrator. Instead of one bundled +// /resume, Sim drives one resume chain per child CONCURRENTLY so a fast child +// never waits on a slow sibling, and the Go join wakes the orchestrator on +// whichever child finishes last. Gated by the Go `subagent-checkpoints` flag, +// surfaced here purely by frames carrying their own checkpointId. +// +// IMPORTANT (concurrency): JS is single-threaded, so the legs interleave at await +// points rather than running truly in parallel; shared accumulators +// (contentBlocks, toolCalls maps, errors) are appended via atomic synchronous +// ops and stay shared by reference. Only the per-leg STREAM CONTROL flags +// (streamComplete, awaitingAsyncContinuation) and the join-leg scalars +// (accumulatedContent/usage/cost) are isolated per leg and merged back. + +type AsyncContinuation = NonNullable + +function isPerSubagentContinuation(c: AsyncContinuation): boolean { + return !!c.frames && c.frames.length > 0 && c.frames.every((f) => !!f.checkpointId) +} + +function resumeRequestHeaders(): Record { + return { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + ...getMothershipSourceEnvHeaders(), + 'X-Client-Version': SIM_AGENT_VERSION, + } +} + +// makeResumeLegContext returns a context that SHARES the heavy accumulators with +// `base` (so all concurrent legs build one merged chat) but isolates the stream +// control flags so a finished leg can't stop its siblings' read loops. +function makeResumeLegContext(base: StreamingContext): StreamingContext { + return { + ...base, + streamComplete: false, + awaitingAsyncContinuation: undefined, + // Isolate the join-leg scalars so each concurrent leg accumulates only its + // OWN output; mergeResumeLegOutputs folds them back exactly once. Without + // zeroing these, the spread seeds every leg with the orchestrator's + // pre-fanout content and the `+=` merge multiplies it by the leg count + // (and stale usage/cost from a child leg could clobber the join leg's real + // totals depending on merge order). + accumulatedContent: '', + finalAssistantContent: '', + usage: undefined, + cost: undefined, + } +} + +// mergeResumeLegOutputs folds a finished leg's isolated scalars back into the +// shared context. Child (subagent-lane) legs leave these empty; only the +// join-carrying leg (which streams the orchestrator continuation) sets them. +function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void { + if (leg.accumulatedContent) context.accumulatedContent += leg.accumulatedContent + if (leg.finalAssistantContent) context.finalAssistantContent += leg.finalAssistantContent + if (leg.usage) context.usage = leg.usage + if (leg.cost) context.cost = leg.cost + if (leg.sawMainToolCall) context.sawMainToolCall = true + if (leg.wasAborted) context.wasAborted = true +} + +async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { + const promises: Promise[] = [] + for (const id of toolIds) { + const p = context.pendingToolPromises.get(id) + if (p) promises.push(p) + } + if (promises.length > 0) await Promise.allSettled(promises) +} + +function collectResultsForToolIds( + context: StreamingContext, + toolIds: string[], + checkpointId: string +): Array<{ callId: string; name: string; data: unknown; success: boolean }> { + return toolIds.map((toolCallId) => { + const tool = context.toolCalls.get(toolCallId) + if (!tool || !tool.result) { + throw new Error( + `Cannot resume subagent chain ${checkpointId}: missing result for tool call ${toolCallId}` + ) + } + return { + callId: toolCallId, + name: tool.name || '', + data: getToolCallTerminalData(tool), + success: requireToolCallStateResult(tool).success, + } + }) +} + +// driveOneChildChain resumes a single subagent's checkpoint chain to its end: +// resume -> (re-pause -> resume)* -> fold into join. Returns the orchestrator's +// follow-on continuation when THIS leg is the one the Go join woke (the last +// finisher whose /resume response carried the orchestrator continuation), else +// null. Re-pause vs follow-on is disambiguated by checkpoint id: a re-pause keeps +// the same child id; the join continuation is a different (orchestrator) id. +async function driveOneChildChain( + frame: NonNullable[number], + context: StreamingContext, + execContext: ExecutionContext, + options: CopilotLifecycleOptions, + baseURL: string, + workspaceId?: string +): Promise { + // ParentToolCallID is the SAME subagent's stable identity across re-pauses; + // the checkpoint id rotates each re-pause (the prior one is already claimed). + const parentToolCallId = frame.parentToolCallId + let checkpointId = frame.checkpointId as string + let toolIds = frame.pendingToolIds + + for (;;) { + if (isAborted(options, context)) return null + + await waitForToolIds(context, toolIds) + const results = collectResultsForToolIds(context, toolIds, checkpointId) + + const leg = makeResumeLegContext(context) + await runStreamLoop( + `${baseURL}/api/tools/resume`, + { + method: 'POST', + headers: resumeRequestHeaders(), + body: JSON.stringify({ + streamId: context.messageId, + checkpointId, + userId: options.userId, + ...(workspaceId ? { workspaceId } : {}), + results, + }), + }, + leg, + execContext, + options + ) + mergeResumeLegOutputs(context, leg) + + const cont = leg.awaitingAsyncContinuation + if (!cont) { + // The last finisher's leg, whose join continuation streamed the + // orchestrator to completion (done): nothing more to drive on this leg. + return null + } + // A NON-last finisher folds with a TERMINAL pause carrying the join id but + // NO pending tools and NO frames — the child's work is done and the join + // wakes on whichever sibling finishes last. End this leg cleanly; do NOT + // mistake the join id for an orchestrator follow-on and try to resume it. + const hasPending = (cont.pendingToolCallIds?.length ?? 0) > 0 + const hasFrames = (cont.frames?.length ?? 0) > 0 + if (!hasPending && !hasFrames) { + return null + } + // Re-pause is identified by THIS subagent's stable parentToolCallId (the + // checkpoint id rotates each re-pause). If present, keep driving this child + // with its new id + leaves. + const repaused = cont.frames?.find( + (f) => f.parentToolCallId === parentToolCallId && f.checkpointId + ) + if (repaused?.checkpointId) { + checkpointId = repaused.checkpointId + toolIds = repaused.pendingToolIds + continue + } + // No frame for this subagent => the join fired and the orchestrator re-paused + // on this leg. Hand it back to the main loop to continue the turn. + return cont + } +} + +// driveSubagentChains fans out one resume chain per child frame concurrently and +// returns the single orchestrator follow-on continuation (if the orchestrator +// re-paused after the join), or null when the turn completed. +async function driveSubagentChains( + continuation: AsyncContinuation, + context: StreamingContext, + execContext: ExecutionContext, + options: CopilotLifecycleOptions, + baseURL: string, + workspaceId?: string +): Promise { + const frames = continuation.frames ?? [] + logger.info('Driving subagent checkpoint chains concurrently', { + childCount: frames.length, + checkpointIds: frames.map((f) => f.checkpointId), + }) + const followOns = await Promise.all( + frames.map((frame) => + driveOneChildChain(frame, context, execContext, options, baseURL, workspaceId) + ) + ) + return followOns.find((c): c is AsyncContinuation => !!c) ?? null +} + // --------------------------------------------------------------------------- // Checkpoint loop – the core state machine // --------------------------------------------------------------------------- @@ -405,9 +603,38 @@ async function runCheckpointLoop( break } - const continuation = context.awaitingAsyncContinuation + let continuation = context.awaitingAsyncContinuation if (!continuation) break + // Per-subagent checkpoint model: fan out one concurrent resume chain per + // child instead of a single bundled resume. The driver returns null when the + // turn completed, or the orchestrator's follow-on continuation when it + // re-paused after the join. A per-subagent follow-on (orchestrator spawned + // more subagents) loops back through the driver; a normal follow-on falls + // through to the sequential resume path below. + if (isPerSubagentContinuation(continuation)) { + context.awaitingAsyncContinuation = undefined + let next: AsyncContinuation | null = continuation + while (next && isPerSubagentContinuation(next)) { + if (isAborted(options, context)) { + cancelPendingTools(context) + next = null + break + } + await waitForToolIds(context, next.pendingToolCallIds) + next = await driveSubagentChains( + next, + context, + execContext, + options, + mothershipBaseURL, + lifecycleWorkspaceId + ) + } + if (!next) break + continuation = next + } + if (context.pendingToolPromises.size > 0) { // Bounded by the slowest pending tool's watchdog plus grace. The // per-tool watchdog already guarantees each promise settles; this gate diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index b1637b017ce..f479d5e99b7 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -265,7 +265,13 @@ class ToolExecutionTimeoutError extends Error { */ async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) - const execution = executeTool(toolCall.name, toolCall.params || {}, execContext) + // Thread the invoking subagent's channel id per call (execContext is shared + // across the whole turn, so the channel id can't live on it) — server tools + // use it to scope the workspace_file -> edit_content intent handoff. + const toolContext = toolCall.parentToolCallId + ? { ...execContext, parentToolCallId: toolCall.parentToolCallId } + : execContext + const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) let timer: ReturnType | undefined try { return await Promise.race([ diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 0898bdbe442..0078b78b233 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -28,6 +28,14 @@ export interface ToolCallState { error?: string startTime?: number endTime?: number + /** + * For a subagent-scoped tool call, the invoking subagent's channel id (its + * outer tool_use id, = event.scope.parentToolCallId). Captured at dispatch so + * the executor can thread it into the server tool context and scope the + * workspace_file -> edit_content intent handoff per file subagent. Undefined + * for main-lane tool calls. + */ + parentToolCallId?: string } export type ToolCallResult = ToolExecutionResult & { @@ -67,6 +75,15 @@ export interface ContentBlock { parentSpanId?: string } +export interface ActiveFileIntent { + toolCallId: string + operation: string + target: { kind: string; fileId?: string; fileName?: string; path?: string } + title?: string + contentType?: string + edit?: Record +} + export interface StreamingContext { chatId?: string requestId?: string @@ -88,11 +105,28 @@ export interface StreamingContext { parentToolCallId: string parentToolName: string pendingToolIds: string[] + // Per-subagent checkpoint model: this frame's OWN checkpoint chain. When + // set, the resume loop must POST /api/tools/resume with THIS id (not the + // top-level checkpointId) carrying only this frame's leaf results, and may + // drive the N frames concurrently. Empty under the bundled-frame model. + checkpointId?: string }> } currentThinkingBlock: ContentBlock | null - currentSubagentThinkingBlock: ContentBlock | null + /** + * Open subagent "thinking" blocks, keyed by parentToolCallId (one lane per + * concurrent subagent). Was a single slot, which collided when two subagents + * streamed thinking concurrently — interleaved chunks flushed each other's + * block. Per-lane keying keeps each subagent's reasoning intact. + */ + subagentThinkingBlocks: Map isInThinkingBlock: boolean + /** + * @deprecated Legacy single "current subagent" pointer. Attribution is now + * scope-only (every subagent event carries its own parentToolCallId/spanId), + * so this is no longer read for routing. Retained as a write-only field for + * back-compat with the span-stack bookkeeping in go/stream.ts. + */ subAgentParentToolCallId?: string subAgentParentStack: string[] subAgentContent: Record @@ -104,14 +138,14 @@ export interface StreamingContext { errors: string[] usage?: { prompt: number; completion: number } cost?: { input: number; output: number; total: number } - activeFileIntent?: { - toolCallId: string - operation: string - target: { kind: string; fileId?: string; fileName?: string; path?: string } - title?: string - contentType?: string - edit?: Record - } | null + /** + * In-flight file-write intents keyed by the file subagent's channel id + * (event.scope.parentToolCallId). Was a single slot, which cross-attributed + * streamed content when two file subagents wrote concurrently; per-channel + * keying isolates each agent's preview. The empty-string key holds the + * main-lane / no-scope intent (file writes there are always sequential). + */ + activeFileIntents: Map trace: TraceCollector subAgentTraceSpans?: Map } diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 9087f38f634..f1c2eae27f5 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -11,6 +11,13 @@ export interface ToolExecutionContext { copilotToolExecution?: boolean requestMode?: string currentAgentId?: string + /** + * The invoking subagent's channel id (its outer tool_use id), threaded per + * tool call so server tools can scope state to one subagent invocation. Two + * concurrent file subagents share currentAgentId ("file") but have distinct + * parentToolCallIds, so this — not currentAgentId — disambiguates them. + */ + parentToolCallId?: string abortSignal?: AbortSignal userTimezone?: string userPermission?: string diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 777c4649019..a83fa4a8b84 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -20,6 +20,7 @@ export function createServerToolHandler(toolId: string): ToolHandler { userPermission: context.userPermission ?? undefined, chatId: context.chatId, messageId: context.messageId, + parentToolCallId: context.parentToolCallId, abortSignal: context.abortSignal, }) diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index 6131d552962..dc03ae805b2 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -6,6 +6,13 @@ export interface ServerToolContext { userPermission?: string chatId?: string messageId?: string + /** + * The invoking subagent's channel id (its outer tool_use id). Used to scope + * the workspace_file -> edit_content intent handoff to a single file subagent + * so two file agents writing concurrently never consume each other's pending + * intent. Undefined for main-agent tool calls (which never overlap). + */ + parentToolCallId?: string abortSignal?: AbortSignal /** Fires only on explicit user stop, never on passive transport disconnect. */ userStopSignal?: AbortSignal diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 8bedce47b41..a84cea989d4 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -49,9 +49,15 @@ export const editContentServerTool: BaseServerTool ({ getRedisClient: () => null })) + +function makeIntent(overrides: Partial): PendingFileIntent { + return { + operation: 'update', + fileId: 'file-x', + workspaceId: 'ws-1', + userId: 'user-1', + chatId: 'chat-1', + messageId: 'msg-1', + fileRecord: { id: overrides.fileId ?? 'file-x' } as unknown as PendingFileIntent['fileRecord'], + createdAt: Date.now(), + ...overrides, + } +} + +function uniqueWorkspace(): string { + return `ws-${Math.random().toString(36).slice(2)}` +} + +describe('file-intent-store channel scoping', () => { + it('consumes the intent for the requesting channel, not the latest in the message', async () => { + const ws = uniqueWorkspace() + const scope = { chatId: 'chat-1', messageId: 'msg-1' } + + // Two concurrent file subagents: A declares fileA on channel F1 first, then + // B declares fileB on channel F2 (later createdAt = the "latest" in message). + await storeFileIntent( + ws, + 'fileA', + makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() }) + ) + await storeFileIntent( + ws, + 'fileB', + makeIntent({ + workspaceId: ws, + fileId: 'fileB', + channelId: 'F2', + createdAt: Date.now() + 1000, + }) + ) + + // edit_content from channel F1 must get fileA — NOT the latest (fileB). + const a = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' }) + expect(a?.fileId).toBe('fileA') + + // edit_content from channel F2 gets fileB. + const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' }) + expect(b?.fileId).toBe('fileB') + }) + + it('only consumes its own channel, leaving the sibling intent intact', async () => { + const ws = uniqueWorkspace() + const scope = { chatId: 'chat-1', messageId: 'msg-1' } + await storeFileIntent( + ws, + 'fileA', + makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() }) + ) + await storeFileIntent( + ws, + 'fileB', + makeIntent({ + workspaceId: ws, + fileId: 'fileB', + channelId: 'F2', + createdAt: Date.now() + 1000, + }) + ) + + await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' }) + // The sibling (F2) is untouched and still consumable afterward. + const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' }) + expect(b?.fileId).toBe('fileB') + }) + + it('falls back to latest-in-message when no channelId (legacy / main-agent)', async () => { + const ws = uniqueWorkspace() + const scope = { chatId: 'chat-1', messageId: 'msg-1' } + await storeFileIntent( + ws, + 'fileA', + makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() }) + ) + await storeFileIntent( + ws, + 'fileB', + makeIntent({ + workspaceId: ws, + fileId: 'fileB', + channelId: 'F2', + createdAt: Date.now() + 1000, + }) + ) + const latest = await consumeLatestFileIntent(ws, scope) + expect(latest?.fileId).toBe('fileB') + }) + + it('returns undefined when the requesting channel has no pending intent', async () => { + const ws = uniqueWorkspace() + await storeFileIntent( + ws, + 'fileA', + makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() }) + ) + const none = await consumeLatestFileIntent(ws, { + chatId: 'chat-1', + messageId: 'msg-1', + channelId: 'F-absent', + }) + expect(none).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts index a7a0a51fff9..82f7977b17d 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts @@ -11,6 +11,11 @@ export type PendingFileIntent = { userId: string chatId?: string messageId?: string + // The invoking file subagent's channel id (its outer tool_use id). Lets + // edit_content consume the intent for ITS OWN file subagent instead of the + // latest in the message, so two file agents writing concurrently never cross + // their content into each other's file. + channelId?: string fileRecord: WorkspaceFileRecord existingContent?: string edit?: { @@ -33,6 +38,10 @@ export type PendingFileIntent = { export type FileIntentScope = { chatId?: string messageId?: string + // When set, consumeLatestFileIntent only considers intents from this subagent + // channel — the key to isolating concurrent file subagents. Omitted by callers + // that intentionally span the whole message (e.g. clearIntentsForWorkspace). + channelId?: string } const logger = createLogger('FileIntentStore') @@ -55,6 +64,14 @@ function scopeMatches(intent: PendingFileIntent, scope?: FileIntentScope): boole return intent.chatId === scope?.chatId && intent.messageId === scope?.messageId } +// Channel filter for consume: when a scope carries a channelId, only the +// matching file subagent's intent qualifies. No channelId => message-wide +// (legacy / main-agent) behavior. Deliberately separate from scopeMatches so +// clearIntentsForWorkspace keeps clearing every channel in a message. +function channelMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean { + return !scope?.channelId || intent.channelId === scope.channelId +} + function buildScopedField(fileId: string, scope?: FileIntentScope): string { return `${scope?.chatId ?? ''}:${scope?.messageId ?? ''}:${fileId}` } @@ -200,7 +217,11 @@ export async function consumeLatestFileIntent( let latest: PendingFileIntent | undefined let latestKey: string | undefined for (const [key, intent] of memoryStore) { - if (intent.workspaceId === workspaceId && scopeMatches(intent, scope)) { + if ( + intent.workspaceId === workspaceId && + scopeMatches(intent, scope) && + channelMatches(intent, scope) + ) { if (!latest || intent.createdAt > latest.createdAt) { latest = intent latestKey = key @@ -225,7 +246,7 @@ export async function consumeLatestFileIntent( staleFields.push(field) continue } - if (!scopeMatches(parsed, scope)) { + if (!scopeMatches(parsed, scope) || !channelMatches(parsed, scope)) { continue } if (!latest || parsed.createdAt > latest.createdAt) { diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index bcea17ddb7d..a490441d4cc 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -428,6 +428,7 @@ export const workspaceFileServerTool: BaseServerTool Date: Tue, 16 Jun 2026 17:03:59 -0700 Subject: [PATCH 02/11] fix(subagents): address parallel-subagent bugs --- .../request/context/request-context.ts | 2 - .../copilot/request/context/result.test.ts | 2 - .../sim/lib/copilot/request/go/stream.test.ts | 2 - apps/sim/lib/copilot/request/go/stream.ts | 17 +- .../copilot/request/handlers/handlers.test.ts | 6 - .../lifecycle/resume-leg-context.test.ts | 82 +++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 167 ++++++++++++++---- apps/sim/lib/copilot/request/types.ts | 51 +++--- 8 files changed, 239 insertions(+), 90 deletions(-) create mode 100644 apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 2f3a76ba5eb..a38e68a35d1 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -20,8 +20,6 @@ export function createStreamingContext(overrides?: Partial): S currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], subAgentContent: {}, subAgentToolCalls: {}, pendingContent: '', diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 437ff36ee86..3ea2b984ad2 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -25,8 +25,6 @@ function makeContext(): StreamingContext { currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], subAgentContent: {}, subAgentToolCalls: {}, pendingContent: '', diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index bb070282f58..396615728c2 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -96,8 +96,6 @@ function createStreamingContext(): StreamingContext { currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], subAgentContent: {}, subAgentToolCalls: {}, pendingContent: '', diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index b4ae93d48d1..880e6483aa8 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -392,10 +392,6 @@ export async function runStreamLoop( flushThinkingBlock(context) if (spanEvt === MothershipStreamV1SpanLifecycleEvent.start) { if (toolCallId) { - if (!context.subAgentParentStack.includes(toolCallId)) { - context.subAgentParentStack.push(toolCallId) - } - context.subAgentParentToolCallId = toolCallId context.subAgentContent[toolCallId] ??= '' context.subAgentToolCalls[toolCallId] ??= [] } @@ -424,20 +420,9 @@ export async function runStreamLoop( if (isPendingPause) { return } - if (toolCallId) { - const idx = context.subAgentParentStack.lastIndexOf(toolCallId) - if (idx >= 0) { - context.subAgentParentStack.splice(idx, 1) - } else { - logger.warn('subagent end without matching start', { toolCallId }) - } - } else { + if (!toolCallId) { logger.warn('subagent end missing toolCallId') } - context.subAgentParentToolCallId = - context.subAgentParentStack.length > 0 - ? context.subAgentParentStack[context.subAgentParentStack.length - 1] - : undefined if (toolCallId) { for (let i = context.contentBlocks.length - 1; i >= 0; i--) { const b = context.contentBlocks[i] diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 25d445e81b0..8d4cb83c669 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -93,8 +93,6 @@ describe('sse-handlers tool lifecycle', () => { currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], subAgentContent: {}, subAgentToolCalls: {}, pendingContent: '', @@ -462,8 +460,6 @@ describe('sse-handlers tool lifecycle', () => { it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) - context.subAgentParentToolCallId = 'parent-1' - context.subAgentParentStack = ['parent-1'] context.toolCalls.set('parent-1', { id: 'parent-1', name: 'workflow', @@ -522,7 +518,6 @@ describe('sse-handlers tool lifecycle', () => { }) it('routes subagent text using the event scope parent tool call id', async () => { - context.subAgentParentToolCallId = 'wrong-parent' context.subAgentContent['parent-1'] = '' await subAgentHandlers.text( @@ -573,7 +568,6 @@ describe('sse-handlers tool lifecycle', () => { it('routes subagent tool calls using the event scope parent tool call id', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) - context.subAgentParentToolCallId = 'wrong-parent' context.toolCalls.set('parent-1', { id: 'parent-1', name: 'deploy', diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts new file mode 100644 index 00000000000..4ffc7bcdbf9 --- /dev/null +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run' + +// Guards the makeResumeLegContext / mergeResumeLegOutputs contract: the two MUST +// stay in lockstep (every per-leg-isolated scalar is reset on leg creation and +// folded back on merge), and the heavy accumulators stay shared by reference so +// all concurrent legs build one chat. This is the regression the inline comment +// warns about — without per-leg isolation the orchestrator's pre-fanout content +// gets multiplied by the leg count on merge. +describe('resume leg context isolate/merge contract', () => { + it('isolates the per-leg scalars while sharing the heavy accumulators by reference', () => { + const base = createStreamingContext({ + accumulatedContent: 'PRE', + finalAssistantContent: 'PRE-FINAL', + usage: { prompt: 10, completion: 5 }, + cost: { input: 1, output: 2, total: 3 }, + errors: ['pre-existing'], + }) + + const leg = makeResumeLegContext(base) + + // Per-leg scalars reset so a leg accumulates only its OWN output. + expect(leg.accumulatedContent).toBe('') + expect(leg.finalAssistantContent).toBe('') + expect(leg.usage).toBeUndefined() + expect(leg.cost).toBeUndefined() + expect(leg.errors).toEqual([]) + expect(leg.streamComplete).toBe(false) + expect(leg.awaitingAsyncContinuation).toBeUndefined() + + // A leg's own errors array is a fresh array (not the shared one) so a leg's + // retry rollback can't truncate a sibling's errors. + expect(leg.errors).not.toBe(base.errors) + + // Heavy accumulators stay shared by reference (one merged chat). + expect(leg.contentBlocks).toBe(base.contentBlocks) + expect(leg.toolCalls).toBe(base.toolCalls) + expect(leg.pendingToolPromises).toBe(base.pendingToolPromises) + expect(leg.subAgentContent).toBe(base.subAgentContent) + }) + + it('folds a leg back exactly once (no double-count of the orchestrator content)', () => { + const base = createStreamingContext({ accumulatedContent: 'PRE', errors: ['pre'] }) + + const leg = makeResumeLegContext(base) + leg.accumulatedContent = 'JOIN' + leg.finalAssistantContent = 'JOIN-FINAL' + leg.usage = { prompt: 100, completion: 50 } + leg.cost = { input: 4, output: 5, total: 9 } + leg.errors.push('leg-err') + + mergeResumeLegOutputs(base, leg) + + // PRE seeded once + the leg's own output appended once — not PRE+PRE+JOIN. + expect(base.accumulatedContent).toBe('PREJOIN') + expect(base.finalAssistantContent).toBe('JOIN-FINAL') + expect(base.usage).toEqual({ prompt: 100, completion: 50 }) + expect(base.cost).toEqual({ input: 4, output: 5, total: 9 }) + expect(base.errors).toEqual(['pre', 'leg-err']) + }) + + it('does not multiply pre-fanout content across many legs (N children + one join leg)', () => { + const base = createStreamingContext({ accumulatedContent: 'PRE' }) + + // Seven child legs that stream subagent content (not main accumulatedContent) + // contribute nothing to the join scalars; only the join-carrying leg does. + for (let i = 0; i < 7; i++) { + const childLeg = makeResumeLegContext(base) + mergeResumeLegOutputs(base, childLeg) + } + const joinLeg = makeResumeLegContext(base) + joinLeg.accumulatedContent = 'SUMMARY' + joinLeg.usage = { prompt: 1, completion: 1 } + mergeResumeLegOutputs(base, joinLeg) + + // Exactly the pre-fanout content + the one join leg's summary — the 7 child + // legs must not each re-append 'PRE'. + expect(base.accumulatedContent).toBe('PRESUMMARY') + expect(base.usage).toEqual({ prompt: 1, completion: 1 }) + }) +}) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 1f9e38856d1..7d03aa35130 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -35,6 +35,8 @@ import type { ExecutionContext, OrchestratorOptions, OrchestratorResult, + ResumeContinuation, + ResumeFrame, StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' @@ -249,13 +251,16 @@ export async function runCopilotLifecycle( // (streamComplete, awaitingAsyncContinuation) and the join-leg scalars // (accumulatedContent/usage/cost) are isolated per leg and merged back. -type AsyncContinuation = NonNullable +type AsyncContinuation = ResumeContinuation function isPerSubagentContinuation(c: AsyncContinuation): boolean { return !!c.frames && c.frames.length > 0 && c.frames.every((f) => !!f.checkpointId) } -function resumeRequestHeaders(): Record { +// Shared header set for every Sim -> Go mothership request (initial stream and +// every resume leg), so the auth/source/version headers can't drift between the +// sequential path and the concurrent per-subagent resume legs. +function mothershipRequestHeaders(): Record { return { 'Content-Type': 'application/json', ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), @@ -264,37 +269,47 @@ function resumeRequestHeaders(): Record { } } -// makeResumeLegContext returns a context that SHARES the heavy accumulators with -// `base` (so all concurrent legs build one merged chat) but isolates the stream -// control flags so a finished leg can't stop its siblings' read loops. -function makeResumeLegContext(base: StreamingContext): StreamingContext { +// makeResumeLegContext / mergeResumeLegOutputs are a PAIR and must stay in +// lockstep: every field reset here is folded back there, and nothing else on +// StreamingContext is per-leg. Everything not listed is shared BY REFERENCE +// across all concurrent legs (the one merged chat: contentBlocks, toolCalls, +// pendingToolPromises, subagent maps, etc.). The per-leg ISOLATED set: +// - streamComplete / awaitingAsyncContinuation: stream-control flags, so a +// finished leg can't stop a sibling's read loop (reset only; not merged). +// - accumulatedContent / finalAssistantContent / usage / cost: join-leg +// scalars — only the join-carrying leg sets them; zeroing per leg keeps the +// `+=` merge from multiplying the orchestrator's pre-fanout content by the +// leg count, and keeps a child leg's stale usage/cost from clobbering the +// join leg's real totals on merge. +// - errors: a leg's transient retryable error (rolled back inside +// runResumeLegWithRetry) must not truncate a concurrent sibling's shared +// error array by index; each leg collects its own and merges the survivors. +// When adding a per-leg field, update BOTH functions (and the contract test in +// resume-leg-context.test.ts). Exported only for that test. +export function makeResumeLegContext(base: StreamingContext): StreamingContext { return { ...base, streamComplete: false, awaitingAsyncContinuation: undefined, - // Isolate the join-leg scalars so each concurrent leg accumulates only its - // OWN output; mergeResumeLegOutputs folds them back exactly once. Without - // zeroing these, the spread seeds every leg with the orchestrator's - // pre-fanout content and the `+=` merge multiplies it by the leg count - // (and stale usage/cost from a child leg could clobber the join leg's real - // totals depending on merge order). accumulatedContent: '', finalAssistantContent: '', usage: undefined, cost: undefined, + errors: [], } } // mergeResumeLegOutputs folds a finished leg's isolated scalars back into the -// shared context. Child (subagent-lane) legs leave these empty; only the -// join-carrying leg (which streams the orchestrator continuation) sets them. -function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void { +// shared context. Child (subagent-lane) legs leave the join scalars empty; only +// the join-carrying leg (which streams the orchestrator continuation) sets them. +export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void { if (leg.accumulatedContent) context.accumulatedContent += leg.accumulatedContent if (leg.finalAssistantContent) context.finalAssistantContent += leg.finalAssistantContent if (leg.usage) context.usage = leg.usage if (leg.cost) context.cost = leg.cost if (leg.sawMainToolCall) context.sawMainToolCall = true if (leg.wasAborted) context.wasAborted = true + if (leg.errors.length > 0) context.errors.push(...leg.errors) } async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { @@ -327,6 +342,54 @@ function collectResultsForToolIds( }) } +// runResumeLegWithRetry runs ONE resume POST with the same retryable-error + +// bounded-backoff policy the sequential checkpoint loop uses, so a concurrent +// child leg survives a transient Go 5xx (or network blip) instead of failing the +// whole turn — Go releases the claim on such errors expecting a retry. The leg's +// transient error is rolled back on its OWN (isolated) errors array so a +// recovered retry isn't mis-finalized as `error`. An AbortError (a sibling +// failure cancelling this leg, see driveSubagentChains) is non-retryable and +// propagates immediately. +async function runResumeLegWithRetry( + url: string, + body: Record, + leg: StreamingContext, + execContext: ExecutionContext, + options: CopilotLifecycleOptions +): Promise { + let attempt = 0 + for (;;) { + const errorsBeforeAttempt = leg.errors.length + const willRetryOnStreamError = attempt < MAX_RESUME_ATTEMPTS - 1 + const legBody = willRetryOnStreamError ? { ...body, willRetryOnStreamError: true } : body + try { + await runStreamLoop( + url, + { method: 'POST', headers: mothershipRequestHeaders(), body: JSON.stringify(legBody) }, + leg, + execContext, + options + ) + return + } catch (error) { + if (isRetryableStreamError(error) && attempt < MAX_RESUME_ATTEMPTS - 1) { + leg.errors.length = errorsBeforeAttempt + attempt++ + const backoff = RESUME_BACKOFF_MS[attempt - 1] ?? 1000 + logger.warn('Child resume leg failed, retrying', { + attempt: attempt + 1, + maxAttempts: MAX_RESUME_ATTEMPTS, + backoffMs: backoff, + error: toError(error).message, + }) + await sleepWithAbort(backoff, options.abortSignal) + continue + } + throw error + } + } +} + // driveOneChildChain resumes a single subagent's checkpoint chain to its end: // resume -> (re-pause -> resume)* -> fold into join. Returns the orchestrator's // follow-on continuation when THIS leg is the one the Go join woke (the last @@ -334,7 +397,7 @@ function collectResultsForToolIds( // null. Re-pause vs follow-on is disambiguated by checkpoint id: a re-pause keeps // the same child id; the join continuation is a different (orchestrator) id. async function driveOneChildChain( - frame: NonNullable[number], + frame: ResumeFrame, context: StreamingContext, execContext: ExecutionContext, options: CopilotLifecycleOptions, @@ -344,7 +407,11 @@ async function driveOneChildChain( // ParentToolCallID is the SAME subagent's stable identity across re-pauses; // the checkpoint id rotates each re-pause (the prior one is already claimed). const parentToolCallId = frame.parentToolCallId - let checkpointId = frame.checkpointId as string + // Guarded (not cast): a per-subagent frame always carries its own checkpointId + // (isPerSubagentContinuation requires it), but a local guard keeps this driver + // correct on its own terms rather than trusting a caller-side invariant. + if (!frame.checkpointId) return null + let checkpointId = frame.checkpointId let toolIds = frame.pendingToolIds for (;;) { @@ -354,18 +421,14 @@ async function driveOneChildChain( const results = collectResultsForToolIds(context, toolIds, checkpointId) const leg = makeResumeLegContext(context) - await runStreamLoop( + await runResumeLegWithRetry( `${baseURL}/api/tools/resume`, { - method: 'POST', - headers: resumeRequestHeaders(), - body: JSON.stringify({ - streamId: context.messageId, - checkpointId, - userId: options.userId, - ...(workspaceId ? { workspaceId } : {}), - results, - }), + streamId: context.messageId, + checkpointId, + userId: options.userId, + ...(workspaceId ? { workspaceId } : {}), + results, }, leg, execContext, @@ -408,6 +471,14 @@ async function driveOneChildChain( // driveSubagentChains fans out one resume chain per child frame concurrently and // returns the single orchestrator follow-on continuation (if the orchestrator // re-paused after the join), or null when the turn completed. +// +// Failure isolation: the legs share a per-fanout AbortController so the FIRST leg +// to fail cancels its siblings' in-flight resumes (otherwise a `Promise.all` +// reject leaves the siblings running detached — still mutating shared context and +// POSTing /resume after the turn has errored). The controller also chains off the +// caller's abort signal so a user stop cancels every leg. Each leg's failure is +// caught (so Promise.all can't reject before its siblings unwind); we then +// rethrow the first REAL error, not the AbortErrors it triggered in the siblings. async function driveSubagentChains( continuation: AsyncContinuation, context: StreamingContext, @@ -421,12 +492,37 @@ async function driveSubagentChains( childCount: frames.length, checkpointIds: frames.map((f) => f.checkpointId), }) - const followOns = await Promise.all( - frames.map((frame) => - driveOneChildChain(frame, context, execContext, options, baseURL, workspaceId) + + const fanoutController = new AbortController() + const parentSignal = options.abortSignal + const onParentAbort = () => fanoutController.abort() + if (parentSignal) { + if (parentSignal.aborted) fanoutController.abort() + else parentSignal.addEventListener('abort', onParentAbort, { once: true }) + } + const legOptions: CopilotLifecycleOptions = { ...options, abortSignal: fanoutController.signal } + + let firstError: unknown + try { + const followOns = await Promise.all( + frames.map((frame) => + driveOneChildChain(frame, context, execContext, legOptions, baseURL, workspaceId).catch( + (error) => { + // First real failure wins and cancels the siblings; their resulting + // AbortErrors arrive later and don't overwrite it. Swallow here so + // Promise.all doesn't reject before every leg has unwound. + if (firstError === undefined) firstError = error + fanoutController.abort() + return null + } + ) + ) ) - ) - return followOns.find((c): c is AsyncContinuation => !!c) ?? null + if (firstError !== undefined) throw firstError + return followOns.find((c): c is AsyncContinuation => !!c) ?? null + } finally { + parentSignal?.removeEventListener('abort', onParentAbort) + } } // --------------------------------------------------------------------------- @@ -537,12 +633,7 @@ async function runCheckpointLoop( `${mothershipBaseURL}${route}`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - ...getMothershipSourceEnvHeaders(), - 'X-Client-Version': SIM_AGENT_VERSION, - }, + headers: mothershipRequestHeaders(), body: JSON.stringify(legPayload), }, context, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 0078b78b233..2efa49fdbcc 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -84,6 +84,32 @@ export interface ActiveFileIntent { edit?: Record } +// One paused subagent frame in an async continuation. Mirrors the wire +// MothershipStreamV1CheckpointPauseFrame the run handler maps from, but is the +// internal shape the resume driver consumes (named once here so the lifecycle +// driver and handlers reference the same type instead of re-declaring it inline). +export interface ResumeFrame { + parentToolCallId: string + parentToolName: string + pendingToolIds: string[] + // Per-subagent checkpoint model: this frame's OWN checkpoint chain. When set, + // the resume loop must POST /api/tools/resume with THIS id (not the top-level + // checkpointId) carrying only this frame's leaf results, and may drive the N + // frames concurrently. Empty under the bundled-frame model. + checkpointId?: string +} + +// The async-continuation state captured from a checkpoint_pause: what the resume +// loop needs to drive the next /resume (the bundled top-level id + pending tools, +// or per-subagent frames each carrying their own checkpointId). +export interface ResumeContinuation { + checkpointId: string + executionId?: string + runId?: string + pendingToolCallIds: string[] + frames?: ResumeFrame[] +} + export interface StreamingContext { chatId?: string requestId?: string @@ -96,22 +122,7 @@ export interface StreamingContext { contentBlocks: ContentBlock[] toolCalls: Map pendingToolPromises: Map> - awaitingAsyncContinuation?: { - checkpointId: string - executionId?: string - runId?: string - pendingToolCallIds: string[] - frames?: Array<{ - parentToolCallId: string - parentToolName: string - pendingToolIds: string[] - // Per-subagent checkpoint model: this frame's OWN checkpoint chain. When - // set, the resume loop must POST /api/tools/resume with THIS id (not the - // top-level checkpointId) carrying only this frame's leaf results, and may - // drive the N frames concurrently. Empty under the bundled-frame model. - checkpointId?: string - }> - } + awaitingAsyncContinuation?: ResumeContinuation currentThinkingBlock: ContentBlock | null /** * Open subagent "thinking" blocks, keyed by parentToolCallId (one lane per @@ -121,14 +132,6 @@ export interface StreamingContext { */ subagentThinkingBlocks: Map isInThinkingBlock: boolean - /** - * @deprecated Legacy single "current subagent" pointer. Attribution is now - * scope-only (every subagent event carries its own parentToolCallId/spanId), - * so this is no longer read for routing. Retained as a write-only field for - * back-compat with the span-stack bookkeeping in go/stream.ts. - */ - subAgentParentToolCallId?: string - subAgentParentStack: string[] subAgentContent: Record subAgentToolCalls: Record openSubagentParents?: Set From 5f836da50e003b0aa4f26150f07fd100d3f02092 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 11:37:09 -0700 Subject: [PATCH 03/11] progress on streaming refactor --- .../agent-group/agent-group.test.ts | 71 +++ .../components/agent-group/agent-group.tsx | 39 +- .../components/agent-group/index.ts | 2 +- .../components/agent-group/tool-call-item.tsx | 9 +- .../components/chat-content/chat-content.tsx | 9 + .../message-content/components/index.ts | 2 +- .../home/components/message-content/index.ts | 1 + .../message-content/message-content.test.ts | 17 + .../message-content/message-content.tsx | 57 +- .../components/message-content/utils.test.ts | 38 ++ .../home/components/message-content/utils.ts | 41 ++ .../mothership-chat/mothership-chat.tsx | 25 +- .../stream/dispatch-stream-event.test.ts | 16 +- .../hooks/stream/dispatch-stream-event.ts | 57 +- .../hooks/stream/handle-complete-event.ts | 27 +- .../home/hooks/stream/handle-error-event.ts | 23 +- .../home/hooks/stream/handle-run-event.ts | 56 +- .../home/hooks/stream/handle-span-event.ts | 90 +-- .../home/hooks/stream/handle-text-event.ts | 54 +- .../hooks/stream/handle-tool-event.test.ts | 153 +++-- .../home/hooks/stream/handle-tool-event.ts | 255 +++----- .../[workspaceId]/home/hooks/stream/index.ts | 1 + .../home/hooks/stream/stream-context.test.ts | 134 +--- .../home/hooks/stream/stream-context.ts | 275 +-------- .../home/hooks/stream/stream-helpers.ts | 89 +-- .../hooks/stream/turn-model-serialize.test.ts | 365 +++++++++++ .../home/hooks/stream/turn-model-serialize.ts | 336 ++++++++++ .../home/hooks/stream/turn-model.test.ts | 468 ++++++++++++++ .../home/hooks/stream/turn-model.ts | 579 ++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 34 +- 30 files changed, 2361 insertions(+), 962 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts new file mode 100644 index 00000000000..842034b9d75 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ToolCallData, ToolCallStatus } from '../../../../types' +import type { AgentGroupItem } from './agent-group' +import { isAgentGroupResolved } from './agent-group' + +let toolSeq = 0 + +function tool(status: ToolCallStatus): AgentGroupItem { + toolSeq += 1 + const data: ToolCallData = { + id: `tool-${toolSeq}`, + toolName: 'grep', + displayTitle: 'Searching', + status, + } + return { type: 'tool', data } +} + +function text(content: string): AgentGroupItem { + return { type: 'text', content } +} + +function group(items: AgentGroupItem[], isDelegating = false): AgentGroupItem { + return { + type: 'agent_group', + group: { + id: `group-${toolSeq}`, + agentName: 'deploy', + agentLabel: 'Deploy', + items, + isDelegating, + isOpen: true, + }, + } +} + +describe('isAgentGroupResolved', () => { + it('is unresolved when there is no work yet', () => { + expect(isAgentGroupResolved([])).toBe(false) + expect(isAgentGroupResolved([text('thinking...')])).toBe(false) + }) + + it('resolves once every own tool is terminal', () => { + expect(isAgentGroupResolved([tool('success')])).toBe(true) + expect(isAgentGroupResolved([tool('success'), tool('error')])).toBe(true) + }) + + it('stays unresolved while any own tool is still executing', () => { + expect(isAgentGroupResolved([tool('success'), tool('executing')])).toBe(false) + }) + + it('resolves a parent whose only work is a finished child group', () => { + expect(isAgentGroupResolved([group([tool('success')])])).toBe(true) + }) + + it('stays unresolved while a nested child is still delegating', () => { + expect(isAgentGroupResolved([group([], true)])).toBe(false) + }) + + it('stays unresolved while a nested child has an executing tool', () => { + expect(isAgentGroupResolved([group([tool('executing')])])).toBe(false) + }) + + it('resolves deep nesting only when every descendant is terminal', () => { + expect(isAgentGroupResolved([group([group([tool('success')])])])).toBe(true) + expect(isAgentGroupResolved([group([group([tool('executing')])])])).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index eb42f24729c..a88a39160b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { ToolCallData } from '../../../../types' -import { getAgentIcon } from '../../utils' +import { getAgentIcon, isToolDone } from '../../utils' import { ToolCallItem } from './tool-call-item' /** @@ -35,15 +35,18 @@ interface AgentGroupProps { defaultExpanded?: boolean } -function isToolDone(status: ToolCallData['status']): boolean { - return ( - status === 'success' || - status === 'error' || - status === 'cancelled' || - status === 'skipped' || - status === 'rejected' || - status === 'interrupted' - ) +export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { + let hasWork = false + for (const item of items) { + if (item.type === 'tool') { + hasWork = true + if (!isToolDone(item.data.status)) return false + } else if (item.type === 'agent_group') { + hasWork = true + if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false + } + } + return hasWork } export function AgentGroup({ @@ -56,20 +59,18 @@ export function AgentGroup({ }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasItems = items.length > 0 - const toolItems = items.filter( - (item): item is Extract => item.type === 'tool' - ) - const allDone = toolItems.length > 0 && toolItems.every((t) => isToolDone(t.data.status)) - // Only a live turn can be delegating. Once the turn is terminal (complete, - // errored, or stopped) no subagent should spin — even one aborted before its - // first tool call, where `allDone` is false because there are no tools yet. - const showDelegatingSpinner = isStreaming && isDelegating && !allDone + const resolved = isAgentGroupResolved(items) + // Pure projection of the run's own state: a subagent header spins while it is + // delegating with no resolved work yet. A terminal turn closes the lane (its + // subagent block is stamped ended), which clears `isDelegating`, so no + // transport gating is needed to stop an aborted-before-first-tool spinner. + const showDelegatingSpinner = isDelegating && !resolved // Expand only while the turn is live and the group is still open or working. // Once the turn ends (isStreaming false) — or a subagent closes mid-turn — the // group auto-collapses, so finished subagent blocks never stay expanded. A // manual toggle pins the choice for the rest of the message. - const autoExpanded = isStreaming && (defaultExpanded || !allDone) + const autoExpanded = isStreaming && (defaultExpanded || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const expanded = manualExpanded ?? autoExpanded diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts index 463df56fd03..812d818e9a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts @@ -1,3 +1,3 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' -export { AgentGroup } from './agent-group' +export { AgentGroup, isAgentGroupResolved } from './agent-group' export { CircleStop } from './tool-call-item' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 02fcccb5044..6b8baa463fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { PillsRing } from '@/components/emcn' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import type { ToolCallStatus } from '../../../../types' -import { getToolIcon } from '../../utils' +import { getToolIcon, resolveToolDisplayState } from '../../utils' function CircleCheck({ className }: { className?: string }) { return ( @@ -58,13 +58,14 @@ function Hyphen({ className }: { className?: string }) { } function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) { - if (status === 'executing') { + const display = resolveToolDisplayState(status) + if (display === 'spinner') { return } - if (status === 'cancelled') { + if (display === 'cancelled') { return } - if (status === 'interrupted') { + if (display === 'interrupted') { return } const Icon = getToolIcon(toolName) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 39acbce7b6c..3075698e179 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -279,6 +279,7 @@ interface ChatContentProps { isStreaming?: boolean onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void + onRevealStateChange?: (isRevealing: boolean) => void } function ChatContentInner({ @@ -286,14 +287,22 @@ function ChatContentInner({ isStreaming = false, onOptionSelect, onWorkspaceResourceSelect, + onRevealStateChange, }: ChatContentProps) { const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect + const onRevealStateChangeRef = useRef(onRevealStateChange) + onRevealStateChangeRef.current = onRevealStateChange + const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content]) const streamedContent = useSmoothText(displayContent, isStreaming) const isRevealing = isStreaming || streamedContent.length < displayContent.length + useEffect(() => { + onRevealStateChangeRef.current?.(isRevealing) + }, [isRevealing]) + /** * One-way latch: once a message has streamed in this mount, keep rendering it * through Streamdown's streaming/animation pipeline for the rest of its life. diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index 8d1105840a6..4a9a1a2ddf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -1,5 +1,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' -export { AgentGroup, CircleStop } from './agent-group' +export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts index 53b2b63195b..1d1a257d880 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts @@ -2,3 +2,4 @@ export { assistantMessageHasRenderableContent, MessageContent, } from './message-content' +export type { MessagePhase } from './utils' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 2f94a31e2cc..b6a93bfbb34 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -56,6 +56,23 @@ describe('parseBlocks span-identity tree', () => { expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true) }) + it('clears the parent delegating flag once it has spawned a child, leaving only the child active', () => { + const blocks: ContentBlock[] = [ + subagentStart('workflow', 'S1', 'main'), + subagentStart('deploy', 'S2', 'S1'), + ] + + const segments = parseBlocks(blocks) + expect(segments).toHaveLength(1) + const workflow = segments[0] + if (workflow.type !== 'agent_group') throw new Error('expected workflow group') + expect(workflow.isDelegating).toBe(false) + + const nested = workflow.items.find((item) => item.type === 'agent_group') + if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group') + expect(nested.group.isDelegating).toBe(true) + }) + it('keeps two top-level subagents as siblings', () => { const blocks: ContentBlock[] = [ subagentStart('workflow', 'S1', 'main'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 4efb6eeb5ff..ac6100a5dbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { stripVersionSuffix } from '@sim/utils/string' import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' @@ -18,6 +18,7 @@ import { PendingTagIndicator, ThinkingBlock, } from './components' +import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' @@ -93,17 +94,6 @@ function resolveAgentLabel(key: string): string { return SUBAGENT_LABELS[key] ?? formatToolName(key) } -function isToolDone(status: ToolCallData['status']): boolean { - return ( - status === 'success' || - status === 'error' || - status === 'cancelled' || - status === 'skipped' || - status === 'rejected' || - status === 'interrupted' - ) -} - function isDelegatingTool(tc: NonNullable): boolean { return tc.status === 'executing' } @@ -226,6 +216,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (parentSpanId && parentSpanId !== SPAN_ROOT) { const parent = groupsBySpanId.get(parentSpanId) if (parent) { + parent.isDelegating = false parent.items.push({ type: 'agent_group', group }) return } @@ -688,6 +679,7 @@ interface MessageContentProps { fallbackContent: string isStreaming: boolean onOptionSelect?: (id: string) => void + onPhaseChange?: (phase: MessagePhase) => void } function MessageContentInner({ @@ -695,10 +687,16 @@ function MessageContentInner({ fallbackContent, isStreaming = false, onOptionSelect, + onPhaseChange, }: MessageContentProps) { const { onWorkspaceResourceSelect } = useChatSurface() const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks]) + const [trailingRevealing, setTrailingRevealing] = useState(false) + const handleTrailingRevealChange = useCallback((revealing: boolean) => { + setTrailingRevealing(revealing) + }, []) + const segments: MessageSegment[] = parsed.length > 0 ? parsed @@ -706,6 +704,17 @@ function MessageContentInner({ ? [{ type: 'text' as const, content: fallbackContent }] : [] + const lastSegment = segments[segments.length - 1] + const hasTrailingTextSegment = lastSegment?.type === 'text' + const isRevealing = hasTrailingTextSegment && trailingRevealing + const phase = deriveMessagePhase({ isStreaming, isRevealing }) + + const onPhaseChangeRef = useRef(onPhaseChange) + onPhaseChangeRef.current = onPhaseChange + useEffect(() => { + onPhaseChangeRef.current?.(phase) + }, [phase]) + if (segments.length === 0) { if (isStreaming) { return ( @@ -717,21 +726,16 @@ function MessageContentInner({ return null } - const lastSegment = segments[segments.length - 1] const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped' - let allLastGroupToolsDone = false - if (lastSegment.type === 'agent_group') { - const toolItems = lastSegment.items.filter((item) => item.type === 'tool') - allLastGroupToolsDone = - toolItems.length > 0 && toolItems.every((t) => t.type === 'tool' && isToolDone(t.data.status)) - } - - const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end') - const showTrailingThinking = - isStreaming && - !hasTrailingContent && - (lastSegment.type === 'thinking' || hasSubagentEnded || allLastGroupToolsDone) + // Deterministic "between steps" signal: the turn is still streaming, nothing + // is actively running (a running tool/subagent renders its own spinner), and + // no trailing text is being revealed. Derived from explicit node state rather + // than guessing from the shape of the last segment. + const hasRunningWork = blocks.some( + (b) => b.toolCall?.status === 'executing' || (b.type === 'subagent' && b.endedAt === undefined) + ) + const showTrailingThinking = phase === 'streaming' && !hasTrailingContent && !hasRunningWork return (
@@ -749,6 +753,9 @@ function MessageContentInner({ })} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} + onRevealStateChange={ + i === segments.length - 1 ? handleTrailingRevealChange : undefined + } /> ) case 'thinking': { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts new file mode 100644 index 00000000000..f0a74e539a7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { deriveMessagePhase, resolveToolDisplayState } from './utils' + +describe('deriveMessagePhase', () => { + it('is streaming whenever the transport is live', () => { + expect(deriveMessagePhase({ isStreaming: true, isRevealing: false })).toBe('streaming') + expect(deriveMessagePhase({ isStreaming: true, isRevealing: true })).toBe('streaming') + }) + + it('is revealing when the transport stopped but text is still draining', () => { + expect(deriveMessagePhase({ isStreaming: false, isRevealing: true })).toBe('revealing') + }) + + it('is settled once neither the transport nor the reveal is active', () => { + expect(deriveMessagePhase({ isStreaming: false, isRevealing: false })).toBe('settled') + }) +}) + +describe('resolveToolDisplayState', () => { + it('spins iff the tool is executing — a pure projection of its own status', () => { + expect(resolveToolDisplayState('executing')).toBe('spinner') + }) + + it('maps cancelled and interrupted to their own glyphs', () => { + expect(resolveToolDisplayState('cancelled')).toBe('cancelled') + expect(resolveToolDisplayState('interrupted')).toBe('interrupted') + }) + + it('renders terminal successes and errors as the tool icon', () => { + expect(resolveToolDisplayState('success')).toBe('icon') + expect(resolveToolDisplayState('error')).toBe('icon') + expect(resolveToolDisplayState('skipped')).toBe('icon') + expect(resolveToolDisplayState('rejected')).toBe('icon') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index b5e05c3e794..7cf99bbf16e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -21,6 +21,7 @@ import { } from '@/components/emcn' import { Calendar, Table as TableIcon } from '@/components/emcn/icons' import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons' +import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' export type IconComponent = ComponentType> @@ -74,3 +75,43 @@ export function getToolIcon(name: string): IconComponent | undefined { const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS] return icon === Blimp ? undefined : icon } + +export type MessagePhase = 'streaming' | 'revealing' | 'settled' + +interface DeriveMessagePhaseArgs { + isStreaming: boolean + isRevealing: boolean +} + +export function deriveMessagePhase({ + isStreaming, + isRevealing, +}: DeriveMessagePhaseArgs): MessagePhase { + if (isStreaming) return 'streaming' + if (isRevealing) return 'revealing' + return 'settled' +} + +type ToolDisplayState = 'spinner' | 'cancelled' | 'interrupted' | 'icon' + +export function resolveToolDisplayState(status: ToolCallStatus): ToolDisplayState { + // Pure projection of the tool's own status. A row spins iff it is genuinely + // executing; every terminal status maps to a glyph. No transport/turn-live + // gating — deterministic terminals (tool `result`, turn propagation) guarantee + // a row never lingers `executing` after its work is done. + if (status === 'executing') return 'spinner' + if (status === 'cancelled') return 'cancelled' + if (status === 'interrupted') return 'interrupted' + return 'icon' +} + +export function isToolDone(status: ToolCallStatus): boolean { + return ( + status === 'success' || + status === 'error' || + status === 'cancelled' || + status === 'skipped' || + status === 'rejected' || + status === 'interrupted' + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index e13bab2f1a8..93bf837d7ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' @@ -9,6 +9,7 @@ import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/componen import { assistantMessageHasRenderableContent, MessageContent, + type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' @@ -155,6 +156,7 @@ interface AssistantMessageRowProps { precedingUserContent?: string rowClassName: string onOptionSelect?: (id: string) => void + onAnimatingChange?: (animating: boolean) => void } const AssistantMessageRow = memo(function AssistantMessageRow({ @@ -163,11 +165,20 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ precedingUserContent, rowClassName, onOptionSelect, + onAnimatingChange, }: AssistantMessageRowProps) { const blocks = message.contentBlocks ?? EMPTY_BLOCKS const hasAnyBlocks = blocks.length > 0 const trimmedContent = message.content?.trim() ?? '' + const [phase, setPhase] = useState(isStreaming ? 'streaming' : 'settled') + + const onAnimatingChangeRef = useRef(onAnimatingChange) + onAnimatingChangeRef.current = onAnimatingChange + useEffect(() => { + onAnimatingChangeRef.current?.(phase !== 'settled') + }, [phase]) + if (!hasAnyBlocks && !trimmedContent && isStreaming) { return } @@ -177,7 +188,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - const showActions = !isStreaming && (message.content || hasAnyBlocks) + const showActions = phase === 'settled' && (message.content || hasAnyBlocks) return (
@@ -186,6 +197,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ fallbackContent={message.content} isStreaming={isStreaming} onOptionSelect={onOptionSelect} + onPhaseChange={setPhase} /> {showActions && (
@@ -229,8 +241,9 @@ export function MothershipChat({ }: MothershipChatProps) { const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting + const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) - const { ref: autoScrollRef } = useAutoScroll(isStreamActive) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) const setScrollElement = useCallback( (el: HTMLDivElement | null) => { scrollElementRef.current = el @@ -282,6 +295,11 @@ export function MothershipChat({ * one extra always-mounted row. */ const lastIndex = messages.length - 1 + const lastRowKey = lastIndex >= 0 ? rowKeyByIndex[lastIndex] : undefined + useEffect(() => { + setLastRowAnimating(false) + }, [lastRowKey]) + const rangeExtractor = useCallback( (range: Range) => { const indexes = defaultRangeExtractor(range) @@ -405,6 +423,7 @@ export function MothershipChat({ precedingUserContent={precedingUserContentByIndex[index]} rowClassName={cn(styles.assistantRow, styles.rowGap)} onOptionSelect={isLast ? stableOnOptionSelect : undefined} + onAnimatingChange={isLast ? setLastRowAnimating : undefined} /> )}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts index 834abbf108c..b0761dc9180 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts @@ -43,14 +43,13 @@ vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event', })) import { dispatchStreamEvent } from './dispatch-stream-event' +import { createTurnModel } from './turn-model' function makeCtx(): StreamLoopContext { return { - state: {} as StreamLoopContext['state'], + state: { model: createTurnModel() } as StreamLoopContext['state'], deps: {} as StreamLoopContext['deps'], - ops: { - resolveScopedSubagent: vi.fn(() => 'sub-agent'), - } as unknown as StreamLoopContext['ops'], + ops: {} as unknown as StreamLoopContext['ops'], } } @@ -92,20 +91,17 @@ describe('dispatchStreamEvent', () => { expect(handlers.handleCompleteEvent).toHaveBeenCalledTimes(1) }) - it('computes and passes per-event scope to scoped handlers', () => { + it('computes and passes per-event scope to the span handler', () => { const ctx = makeCtx() dispatchStreamEvent( ctx, - event(MothershipStreamV1EventType.text, { spanId: 'span-9', agentId: 'agent-x' }) + event(MothershipStreamV1EventType.span, { spanId: 'span-9', agentId: 'agent-x' }) ) - expect(ctx.ops.resolveScopedSubagent).toHaveBeenCalledWith('agent-x', undefined, 'span-9') - const call = handlers.handleTextEvent.mock.calls[0] + const call = handlers.handleSpanEvent.mock.calls[0] expect(call[0]).toBe(ctx) const scope = call[2] expect(scope.scopedSpanId).toBe('span-9') expect(scope.scopedAgentId).toBe('agent-x') - expect(scope.scopedSubagent).toBe('sub-agent') - expect(scope.spanIdentity).toEqual({ spanId: 'span-9' }) }) it('invokes ctx-only handlers (session/run/complete) without a scope argument', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts index dace38d8e90..7d3de4ba00e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts @@ -12,56 +12,45 @@ import type { StreamEventScope, StreamLoopContext, } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { reduceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' -function computeEventScope( - ctx: StreamLoopContext, - parsed: PersistedStreamEventEnvelope -): StreamEventScope { - 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 = ctx.ops.resolveScopedSubagent( - scopedAgentId, - scopedParentToolCallId, - scopedSpanId - ) - const spanIdentity: { spanId?: string; parentSpanId?: string } = { - ...(scopedSpanId ? { spanId: scopedSpanId } : {}), - ...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}), - } +// The model owns subagent attribution by scope identity; only the span handler +// needs scope, and only these three fields (agent id for file-preview seeding, +// span/parent ids for lane identity). +function computeEventScope(parsed: PersistedStreamEventEnvelope): StreamEventScope { return { - scopedSubagent, - scopedParentToolCallId, - scopedAgentId, - scopedSpanId, - scopedParentSpanId, - spanIdentity, + scopedParentToolCallId: + typeof parsed.scope?.parentToolCallId === 'string' + ? parsed.scope.parentToolCallId + : undefined, + scopedAgentId: typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined, + scopedSpanId: typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined, } } /** - * Routes a parsed stream event to its handler. Per-event subagent/span scope is - * resolved once here and passed to the handlers that nest blocks by it. The - * caller's transport loop owns staleness, cursor dedup, and `streamId`/ - * `streamRequestId` updates; this function only mutates the supplied context. + * Folds a parsed stream event into the model (the single source of truth), then + * routes it to its side-effect handler. Span scope is computed only for the span + * handler (handlers no longer nest blocks — the model does). The caller's + * transport loop owns staleness, cursor dedup, and `streamId`/`streamRequestId`. */ export function dispatchStreamEvent( ctx: StreamLoopContext, parsed: PersistedStreamEventEnvelope ): void { - const scope = computeEventScope(ctx, parsed) + // The model is the single source of truth: fold every event into it first, + // then run the handlers for their side effects (resource/query/preview) and + // the snapshot flush, which serializes the model. + reduceEvent(ctx.state.model, parsed) switch (parsed.type) { case MothershipStreamV1EventType.session: handleSessionEvent(ctx, parsed) break case MothershipStreamV1EventType.text: - handleTextEvent(ctx, parsed, scope) + handleTextEvent(ctx, parsed) break case MothershipStreamV1EventType.tool: - handleToolEvent(ctx, parsed, scope) + handleToolEvent(ctx, parsed) break case MothershipStreamV1EventType.resource: handleResourceEvent(ctx, parsed) @@ -70,10 +59,10 @@ export function dispatchStreamEvent( handleRunEvent(ctx, parsed) break case MothershipStreamV1EventType.span: - handleSpanEvent(ctx, parsed, scope) + handleSpanEvent(ctx, parsed, computeEventScope(parsed)) break case MothershipStreamV1EventType.error: - handleErrorEvent(ctx, parsed, scope) + handleErrorEvent(ctx, parsed) break case MothershipStreamV1EventType.complete: handleCompleteEvent(ctx, parsed) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts index 2c6254ac7c6..c4eb9889811 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts @@ -1,25 +1,14 @@ -import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' -import { - asPayloadRecord, - finalizeResidualToolCalls, -} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' type CompleteEvent = Extract -export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEvent): void { - const { state, ops } = ctx - state.sawCompleteEvent = true - ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) - const completeResponse = asPayloadRecord(parsed.payload.response) - if (completeResponse === undefined || !('async_pause' in completeResponse)) { - finalizeResidualToolCalls( - state.blocks, - parsed.payload.status === MothershipStreamV1CompletionStatus.cancelled - ? 'cancelled' - : 'complete' - ) - ops.flush() - } +/** + * Turn termination and the deterministic propagation of the outcome to any + * still-open node are folded into the model by `reduceEvent` (which skips an + * async pause). This handler only records the terminal flag and flushes. + */ +export function handleCompleteEvent(ctx: StreamLoopContext, _parsed: CompleteEvent): void { + ctx.state.sawCompleteEvent = true + ctx.ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts index 6c1d00f42bf..54b11258412 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts @@ -1,23 +1,16 @@ import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import type { - StreamEventScope, - StreamLoopContext, -} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type ErrorEvent = Extract -export function handleErrorEvent( - ctx: StreamLoopContext, - parsed: ErrorEvent, - scope: StreamEventScope -): void { +/** + * The inline error tag is folded into the model by `reduceEvent` (scoped to the + * erroring lane). This handler owns the side effects: flag the stream error and + * surface the message, then flush the serialized snapshot. + */ +export function handleErrorEvent(ctx: StreamLoopContext, parsed: ErrorEvent): void { const { state, ops, deps } = ctx state.sawStreamError = true deps.setError(parsed.payload.message || parsed.payload.error || 'An error occurred') - ops.appendInlineErrorTag( - ops.buildInlineErrorTag(parsed.payload), - scope.scopedSubagent, - ops.resolveParentForSubagentBlock(scope.scopedSubagent, scope.scopedParentToolCallId), - typeof parsed.ts === 'string' ? parsed.ts : undefined - ) + ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts index f8c0d2df81d..4182dd92302 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts @@ -1,55 +1,13 @@ -import { MothershipStreamV1RunKind } from '@/lib/copilot/generated/mothership-stream-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type RunEvent = Extract -export function handleRunEvent(ctx: StreamLoopContext, parsed: RunEvent): void { - const { state, ops } = ctx - const payload = parsed.payload - - if (payload.kind === MothershipStreamV1RunKind.compaction_start) { - const compactionId = `compaction_${Date.now()}` - state.activeCompactionId = compactionId - ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) - state.toolMap.set(compactionId, state.blocks.length) - state.blocks.push({ - type: 'tool_call', - toolCall: { - id: compactionId, - name: 'context_compaction', - status: 'executing', - displayTitle: 'Compacting context...', - }, - timestamp: Date.now(), - }) - ops.flush() - return - } - - if (payload.kind === MothershipStreamV1RunKind.compaction_done) { - const compactionId = state.activeCompactionId || `compaction_${Date.now()}` - state.activeCompactionId = undefined - const idx = state.toolMap.get(compactionId) - if (idx !== undefined && state.blocks[idx]?.toolCall) { - state.blocks[idx].toolCall!.status = 'success' - state.blocks[idx].toolCall!.displayTitle = 'Compacted context' - ops.stampBlockEnd(state.blocks[idx]) - } else { - state.toolMap.set(compactionId, state.blocks.length) - const endNow = Date.now() - state.blocks.push({ - type: 'tool_call', - toolCall: { - id: compactionId, - name: 'context_compaction', - status: 'success', - displayTitle: 'Compacted context', - }, - timestamp: endNow, - endedAt: endNow, - }) - } - ops.flush() - } +/** + * Compaction lifecycle is folded into the model by `reduceEvent` (it opens and + * closes a `context_compaction` node with titles). This handler only flushes the + * serialized snapshot. + */ +export function handleRunEvent(ctx: StreamLoopContext, _parsed: RunEvent): void { + ctx.ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts index 78aca651bcf..7c0902470da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts @@ -14,13 +14,19 @@ import { type SpanEvent = Extract +/** + * Side effects for subagent span lifecycle. The model owns the subagent + * group/nesting/close (via `reduceEvent`); this handler only seeds the file + * preview session on a fresh file-subagent start and reconciles the file + * resource chrome on end, then flushes the model-derived snapshot. + */ export function handleSpanEvent( ctx: StreamLoopContext, parsed: SpanEvent, scope: StreamEventScope ): void { const { state, ops, deps } = ctx - const { scopedParentToolCallId, scopedAgentId, scopedSpanId, spanIdentity } = scope + const { scopedParentToolCallId, scopedAgentId, scopedSpanId } = scope const payload = parsed.payload if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) { return @@ -36,37 +42,12 @@ export function handleSpanEvent( const isPendingPause = spanData?.pending === true const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId - if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) { - const existingOpenForSpan = scopedSpanId - ? state.blocks.some( - (b) => b.type === 'subagent' && b.spanId === scopedSpanId && b.endedAt === undefined - ) - : false - const isSameActiveSubagent = - existingOpenForSpan || - (!scopedSpanId && - state.activeSubagent === name && - Boolean(state.activeSubagentParentToolCallId) && - parentToolCallId === state.activeSubagentParentToolCallId) - if (scopedSpanId) { - state.subagentBySpanId.set(scopedSpanId, name) - } - if (parentToolCallId) { - state.subagentByParentToolCallId.set(parentToolCallId, name) - } - state.activeSubagent = name - state.activeSubagentParentToolCallId = parentToolCallId - if (!isSameActiveSubagent) { - ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) - state.blocks.push({ - type: 'subagent', - content: name, - ...(parentToolCallId ? { parentToolCallId } : {}), - ...spanIdentity, - timestamp: Date.now(), - }) - } - if (name === FILE_SUBAGENT_ID && !isSameActiveSubagent) { + if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name === FILE_SUBAGENT_ID) { + // Seed the pending preview session only on a freshly-opened lane (the agent + // node was created by this event), so concurrent file subagents don't re-seed. + const node = scopedSpanId ? state.model.nodes.get(scopedSpanId) : undefined + const isNewLane = node?.kind === 'agent' && node.seq === parsed.seq + if (isNewLane) { deps.applyPreviewSessionUpdate({ schemaVersion: 1, id: parentToolCallId || 'file-preview', @@ -87,12 +68,6 @@ export function handleSpanEvent( if (isPendingPause) { return } - if (scopedSpanId) { - state.subagentBySpanId.delete(scopedSpanId) - } - if (parentToolCallId) { - state.subagentByParentToolCallId.delete(parentToolCallId) - } if ( deps.previewSessionRef.current && (!deps.activePreviewSessionIdRef.current || @@ -106,45 +81,6 @@ export function handleSpanEvent( deps.setActiveResourceId(lastFileResource.id) } } - // Clear the legacy single pointer only when THIS ending lane is the active - // one (matched by parent tool call id, or an unscoped end). Never clear by - // agent name alone — a concurrent same-name subagent that is still open must - // not be torn down by a sibling's end. Per-lane state lives in the - // subagentBySpanId / subagentByParentToolCallId maps cleared above. - if (!parentToolCallId || parentToolCallId === state.activeSubagentParentToolCallId) { - state.activeSubagent = undefined - state.activeSubagentParentToolCallId = undefined - } - const endNow = Date.now() - if (scopedSpanId) { - for (let i = state.blocks.length - 1; i >= 0; i--) { - const b = state.blocks[i] - if (b.type === 'subagent' && b.spanId === scopedSpanId && b.endedAt === undefined) { - b.endedAt = endNow - break - } - } - } else if (name) { - for (let i = state.blocks.length - 1; i >= 0; i--) { - const b = state.blocks[i] - if ( - b.type === 'subagent' && - b.content === name && - b.endedAt === undefined && - (!parentToolCallId || b.parentToolCallId === parentToolCallId) - ) { - b.endedAt = endNow - break - } - } - } - ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) - state.blocks.push({ - type: 'subagent_end', - ...(parentToolCallId ? { parentToolCallId } : {}), - ...spanIdentity, - timestamp: endNow, - }) ops.flush() } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts index 766c0aa1fe9..ba115ca97d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts @@ -1,51 +1,13 @@ -import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import type { - StreamEventScope, - StreamLoopContext, -} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type TextEvent = Extract -export function handleTextEvent( - ctx: StreamLoopContext, - parsed: TextEvent, - scope: StreamEventScope -): void { - const { state, ops, deps } = ctx - const { scopedSubagent, scopedParentToolCallId, spanIdentity } = scope - - const chunk = parsed.payload.text - if (!chunk) return - - const eventTs = typeof parsed.ts === 'string' ? parsed.ts : undefined - - if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) { - const scopedParentForBlock = ops.resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - const tb = ops.ensureThinkingBlock(scopedSubagent, scopedParentForBlock, eventTs, spanIdentity) - tb.content = (tb.content ?? '') + chunk - ops.flushText() - return - } - - const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main' - const needsBoundaryNewline = - state.lastContentSource !== null && - state.lastContentSource !== contentSource && - state.runningText.length > 0 && - !state.runningText.endsWith('\n') - const scopedParentForBlock = ops.resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - const tb = ops.ensureTextBlock(scopedSubagent, scopedParentForBlock, eventTs, spanIdentity) - const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk - tb.content = (tb.content ?? '') + normalizedChunk - state.runningText += normalizedChunk - state.lastContentSource = contentSource - deps.streamingContentRef.current = state.runningText - ops.flushText() +/** + * Text content is folded into the model by `reduceEvent` (main and subagent + * lanes are kept distinct, so there is no manual boundary-newline). This handler + * only schedules a paced flush of the serialized snapshot. + */ +export function handleTextEvent(ctx: StreamLoopContext, _parsed: TextEvent): void { + ctx.ops.flushText() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index 0dd762aa3a4..1a760f149f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -7,9 +7,6 @@ vi.mock('@/lib/copilot/resources/extraction', () => ({ isResourceToolName: vi.fn(() => false), extractResourcesFromToolResult: vi.fn(() => []), })) -vi.mock('@/lib/copilot/tools/client/hidden-tools', () => ({ - isToolHiddenInUi: vi.fn(() => false), -})) vi.mock('@/lib/copilot/tools/workflow-tools', () => ({ isWorkflowToolName: vi.fn(() => false), })) @@ -18,80 +15,112 @@ vi.mock( () => ({ invalidateResourceQueries: vi.fn() }) ) -import { handleToolEvent } from './handle-tool-event' -import { createStreamLoopContext, type StreamEventScope } from './stream-context' -import { makeStreamLoopDeps } from './stream-test-helpers' - -const SCOPE: StreamEventScope = { - scopedSubagent: undefined, - scopedParentToolCallId: undefined, - scopedAgentId: undefined, - scopedSpanId: undefined, - scopedParentSpanId: undefined, - spanIdentity: {}, -} - -type ToolEvent = Parameters[1] +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import { dispatchStreamEvent } from './dispatch-stream-event' +import { createStreamLoopContext, type StreamLoopContext } from './stream-context' +import { makeStreamLoopDeps, ref } from './stream-test-helpers' +import type { ToolNode } from './turn-model' -function toolCall(id: string, name = 'my_tool'): ToolEvent { +let seq = 0 +function toolEnv(payload: Record): PersistedStreamEventEnvelope { return { type: 'tool', v: 1, - seq: 1, - ts: '2026-01-01T00:00:00Z', - stream: { streamId: 's' }, - payload: { phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name }, - } as unknown as ToolEvent + seq: ++seq, + ts: '', + stream: { streamId: 's', cursor: String(seq) }, + payload, + } as unknown as PersistedStreamEventEnvelope } -function toolResult(id: string, success: boolean, name = 'my_tool'): ToolEvent { +const toolCall = (id: string, name = 'my_tool') => + toolEnv({ phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name }) + +const toolResult = (id: string, success: boolean, name = 'my_tool') => + toolEnv({ + phase: 'result', + executor: 'go', + mode: 'sync', + toolCallId: id, + toolName: name, + success, + status: success ? 'success' : 'error', + }) + +const workspaceFileCall = (id: string) => + toolEnv({ + phase: 'call', + executor: 'sim', + mode: 'async', + toolCallId: id, + toolName: 'workspace_file', + arguments: { operation: 'append', target: { kind: 'file_id', fileId: 'f1' } }, + }) + +const filePreviewComplete = (id: string) => + toolEnv({ previewPhase: 'file_preview_complete', toolCallId: id, toolName: 'workspace_file' }) + +function streamingSession(toolCallId: string): FilePreviewSession { return { - type: 'tool', - v: 1, - seq: 2, - ts: '2026-01-01T00:00:01Z', - stream: { streamId: 's' }, - payload: { - phase: 'result', - executor: 'go', - mode: 'sync', - toolCallId: id, - toolName: name, - success, - status: success ? 'success' : 'error', - }, - } as unknown as ToolEvent + schemaVersion: 1, + id: toolCallId, + streamId: 's', + toolCallId, + status: 'streaming', + fileName: 'doc.md', + previewText: 'hello', + previewVersion: 1, + updatedAt: '', + } } -describe('handleToolEvent', () => { - it('adds an executing tool_call block on a new call and resolves it on the result', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - handleToolEvent(ctx, toolCall('tc-1'), SCOPE) - expect(ctx.state.blocks).toHaveLength(1) - expect(ctx.state.blocks[0].toolCall?.id).toBe('tc-1') - expect(ctx.state.blocks[0].toolCall?.status).toBe('executing') - - handleToolEvent(ctx, toolResult('tc-1', true), SCOPE) - expect(ctx.state.blocks[0].toolCall?.status).toBe('success') - expect(ctx.state.blocks[0].endedAt).toBeTypeOf('number') +function toolNode(ctx: StreamLoopContext, id: string): ToolNode { + const node = ctx.state.model.nodes.get(id) + expect(node?.kind).toBe('tool') + return node as ToolNode +} + +describe('tool events (dispatch → model + side effects)', () => { + it('runs a tool then settles success, firing the onToolResult side effect', () => { + const onToolResult = vi.fn() + const ctx = createStreamLoopContext(makeStreamLoopDeps({ onToolResultRef: ref(onToolResult) })) + dispatchStreamEvent(ctx, toolCall('tc-1')) + expect(toolNode(ctx, 'tc-1').status).toBe('running') + + dispatchStreamEvent(ctx, toolResult('tc-1', true)) + expect(toolNode(ctx, 'tc-1').status).toBe('success') + expect(onToolResult).toHaveBeenCalledWith('my_tool', true, undefined) }) - it('buffers a result that arrives before its call, then applies it when the call lands', () => { + it('buffers a result that arrives before its call, then applies it', () => { const ctx = createStreamLoopContext(makeStreamLoopDeps()) - handleToolEvent(ctx, toolResult('tc-2', true), SCOPE) - expect(ctx.state.blocks).toHaveLength(0) - expect(ctx.state.pendingToolResults.has('tc-2')).toBe(true) - - handleToolEvent(ctx, toolCall('tc-2'), SCOPE) - expect(ctx.state.blocks).toHaveLength(1) - expect(ctx.state.blocks[0].toolCall?.status).toBe('success') - expect(ctx.state.pendingToolResults.has('tc-2')).toBe(false) + dispatchStreamEvent(ctx, toolResult('tc-2', true)) + expect(ctx.state.model.nodes.has('tc-2')).toBe(false) + + dispatchStreamEvent(ctx, toolCall('tc-2')) + expect(toolNode(ctx, 'tc-2').status).toBe('success') }) it('marks an unsuccessful result as error', () => { const ctx = createStreamLoopContext(makeStreamLoopDeps()) - handleToolEvent(ctx, toolCall('tc-3'), SCOPE) - handleToolEvent(ctx, toolResult('tc-3', false), SCOPE) - expect(ctx.state.blocks[0].toolCall?.status).toBe('error') + dispatchStreamEvent(ctx, toolCall('tc-3')) + dispatchStreamEvent(ctx, toolResult('tc-3', false)) + expect(toolNode(ctx, 'tc-3').status).toBe('error') + }) + + it('settles a file-write row on its own result, independent of a streaming preview session', () => { + const previewSessionsRef = ref>({}) + const ctx = createStreamLoopContext(makeStreamLoopDeps({ previewSessionsRef })) + dispatchStreamEvent(ctx, workspaceFileCall('wf-1')) + expect(toolNode(ctx, 'wf-1').status).toBe('running') + + previewSessionsRef.current['wf-1'] = streamingSession('wf-1') + dispatchStreamEvent(ctx, toolResult('wf-1', true, 'workspace_file')) + expect(toolNode(ctx, 'wf-1').status).toBe('success') + + // A later file_preview_complete is a preview-only signal; the tool row stays settled. + dispatchStreamEvent(ctx, filePreviewComplete('wf-1')) + expect(toolNode(ctx, 'wf-1').status).toBe('success') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index 20e36e0b145..1b2004a9a3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -1,5 +1,4 @@ import { - MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, MothershipStreamV1ToolStatus, } from '@/lib/copilot/generated/mothership-stream-v1' @@ -9,111 +8,88 @@ import { extractResourcesFromToolResult, isResourceToolName, } from '@/lib/copilot/resources/extraction' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' -import type { - StreamEventScope, - StreamLoopContext, -} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { - asPayloadRecord, DEPLOY_TOOL_NAMES, extractResourceFromReadResult, FILE_SUBAGENT_ID, FOLDER_TOOL_NAMES, - getToolUI, - isTerminalToolCallStatus, - resolveLiveToolStatus, - resolveStreamingToolDisplayTitle, - resolveToolDisplayTitle, - type ToolResultPhasePayload, WORKFLOW_MUTATION_TOOL_NAMES, } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' -import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { + MAIN_SPAN, + resolveToolId, + type ToolNode, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' import { deploymentKeys } from '@/hooks/queries/deployments' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/workflows' type ToolEvent = Extract -function applyToolResult( - ctx: StreamLoopContext, - idx: number, - id: string, - payload: ToolResultPhasePayload -): void { - const { state, ops, deps } = ctx - const tc = state.blocks[idx].toolCall! - const outputObj = asPayloadRecord(payload.output) - const isCancelled = - outputObj?.reason === 'user_cancelled' || - outputObj?.cancelledByUser === true || - payload.status === MothershipStreamV1ToolOutcome.cancelled - const status = isCancelled ? ToolCallStatus.cancelled : resolveLiveToolStatus(payload) - const isSuccess = status === ToolCallStatus.success - - if (status === ToolCallStatus.cancelled) { - tc.status = ToolCallStatus.cancelled - tc.displayTitle = 'Stopped by user' - } else { - tc.status = status - } - tc.streamingArgs = undefined - tc.result = { - success: isSuccess, - output: payload.output, - error: typeof payload.error === 'string' ? payload.error : undefined, - } - ops.stampBlockEnd(state.blocks[idx]) - ops.flush() +/** The display agent id for a tool's owning span (undefined on the main lane). */ +function agentIdForSpan(ctx: StreamLoopContext, spanId: string): string | undefined { + if (spanId === MAIN_SPAN) return undefined + const agent = ctx.state.model.nodes.get(spanId) + return agent?.kind === 'agent' ? agent.agentId : undefined +} - if (tc.name === ReadTool.id && tc.status === 'success') { - const readArgs = state.toolArgsMap.get(id) +/** + * Runs the external side effects of a finished tool (resource extraction, query + * invalidation, file-resource promotion, preview cleanup, onToolResult). The + * tool's lifecycle/status is owned by the model; this reads the settled node and + * only performs side effects, so the model stays the single source of state. + */ +function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void { + const { deps } = ctx + const name = node.name + const output = node.result?.output + const isSuccess = node.status === 'success' + const params = node.args + const calledBy = agentIdForSpan(ctx, node.spanId) + + if (name === ReadTool.id && isSuccess) { const resource = extractResourceFromReadResult( - typeof readArgs?.path === 'string' ? readArgs.path : undefined, - tc.result.output + typeof params?.path === 'string' ? params.path : undefined, + output ) if (resource && deps.addResource(resource)) { deps.onResourceEventRef.current?.() } } - if (DEPLOY_TOOL_NAMES.has(tc.name) && tc.status === 'success') { - const output = tc.result?.output as Record | undefined - const deployedWorkflowId = (output?.workflowId as string) ?? undefined - if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') { + if (DEPLOY_TOOL_NAMES.has(name) && isSuccess) { + const out = output as Record | undefined + const deployedWorkflowId = (out?.workflowId as string) ?? undefined + if (deployedWorkflowId && typeof out?.isDeployed === 'boolean') { deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.info(deployedWorkflowId) }) deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(deployedWorkflowId) }) deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) }) } } - if (FOLDER_TOOL_NAMES.has(tc.name) && tc.status === 'success') { + if (FOLDER_TOOL_NAMES.has(name) && isSuccess) { deps.queryClient.invalidateQueries({ queryKey: folderKeys.list(deps.workspaceId) }) } - if (WORKFLOW_MUTATION_TOOL_NAMES.has(tc.name) && tc.status === 'success') { + if (WORKFLOW_MUTATION_TOOL_NAMES.has(name) && isSuccess) { deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) }) } const extractedResources = - tc.status === 'success' && isResourceToolName(tc.name) - ? extractResourcesFromToolResult( - tc.name, - state.toolArgsMap.get(id) as Record | undefined, - tc.result?.output - ) + isSuccess && isResourceToolName(name) + ? extractResourcesFromToolResult(name, params, output) : [] - for (const resource of extractedResources) { invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id) } - if ((tc.name === 'edit_content' || tc.name === WorkspaceFile.id) && tc.status === 'success') { - const editOutput = tc.result?.output as Record | undefined + if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) { + const out = output as Record | undefined const editData = - editOutput && typeof editOutput.data === 'object' && editOutput.data !== null - ? (editOutput.data as Record) + out && typeof out.data === 'object' && out.data !== null + ? (out.data as Record) : undefined const editedFileId = (typeof editData?.id === 'string' ? editData.id : undefined) ?? @@ -135,162 +111,81 @@ function applyToolResult( } } - deps.onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output) + deps.onToolResultRef.current?.(name, isSuccess, output) const workspaceFileOperation = - tc.name === WorkspaceFile.id && typeof tc.params?.operation === 'string' - ? tc.params.operation + name === WorkspaceFile.id && typeof params?.operation === 'string' + ? params.operation : undefined const shouldKeepWorkspacePreviewOpen = - tc.name === WorkspaceFile.id && + name === WorkspaceFile.id && (workspaceFileOperation === 'append' || workspaceFileOperation === 'update' || workspaceFileOperation === 'patch') - if ( - (tc.name === WorkspaceFile.id || tc.name === 'edit_content') && - !shouldKeepWorkspacePreviewOpen - ) { - if (tc.name === WorkspaceFile.id) { - deps.removePreviewSessionImmediate(id) + if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) { + if (name === WorkspaceFile.id) { + deps.removePreviewSessionImmediate(node.id) } const fileResource = extractedResources.find((r) => r.type === 'file') if (fileResource) { deps.promoteFileResource(fileResource.id, fileResource.title) deps.setActiveResourceId(fileResource.id) invalidateResourceQueries(deps.queryClient, deps.workspaceId, 'file', fileResource.id) - } else if (tc.calledBy !== FILE_SUBAGENT_ID) { + } else if (calledBy !== FILE_SUBAGENT_ID) { deps.setResources((rs) => rs.filter((r) => r.id !== 'streaming-file')) } } } -export function handleToolEvent( - ctx: StreamLoopContext, - parsed: ToolEvent, - scope: StreamEventScope -): void { +/** + * Side effects for tool events. State (the tool node, its status, args, and the + * edit_content row merge) is owned by `reduceEvent`; this handler routes preview + * phases, fires client workflow tools, and runs result side effects, then + * flushes the model-derived snapshot. + */ +export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void { const { state, ops, deps } = ctx - const { scopedSubagent, scopedParentToolCallId, spanIdentity } = scope const payload = parsed.payload - const id = payload.toolCallId + const rawId = payload.toolCallId if ('previewPhase' in payload) { + // The file preview panel is a separate concern: forward the phase to the + // preview controller, never coupling it to tool-row status. deps.onPreviewPhase(payload, parsed.stream?.streamId) return } if (payload.phase === MothershipStreamV1ToolPhase.args_delta) { - const delta = payload.argumentsDelta - if (!delta) return - - const idx = state.toolMap.get(id) - if (idx !== undefined && state.blocks[idx].toolCall) { - const tc = state.blocks[idx].toolCall! - tc.streamingArgs = (tc.streamingArgs ?? '') + delta - const displayTitle = resolveStreamingToolDisplayTitle(tc.name, tc.streamingArgs) - if (displayTitle) tc.displayTitle = displayTitle - - ops.flush() - } + ops.flushText() return } + const node = state.model.nodes.get(resolveToolId(state.model, rawId)) + if (payload.phase === MothershipStreamV1ToolPhase.result) { - const idx = state.toolMap.get(id) - if (idx === undefined || !state.blocks[idx].toolCall) { - state.pendingToolResults.set(id, payload) - return - } - applyToolResult(ctx, idx, id, payload) + if (node?.kind === 'tool' && node.result) runToolResultSideEffects(ctx, node) + ops.flush() return } + // Call phase. If a buffered result-before-call was applied to this node by the + // reducer, run its side effects now (the result event had no node to act on). + if (node?.kind === 'tool' && node.result) runToolResultSideEffects(ctx, node) + const name = payload.toolName const isPartial = payload.partial === true || payload.status === MothershipStreamV1ToolStatus.generating - if (isToolHiddenInUi(name)) { - return - } - const ui = getToolUI(payload.ui) - if (ui?.hidden) return - let displayTitle = ui?.title - const args = payload.arguments as Record | undefined - - displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle - - if (name === 'edit_content') { - const parentToolCallId = deps.latestPreviewTargetToolCallIdRef.current - const parentIdx = parentToolCallId !== null ? state.toolMap.get(parentToolCallId) : undefined - const parentToolCall = parentIdx !== undefined ? state.blocks[parentIdx].toolCall : undefined - const parentPreviewSession = - parentToolCallId !== null ? deps.previewSessionsRef.current[parentToolCallId] : undefined - const canReuseParentRow = - parentToolCall !== undefined && - (!isTerminalToolCallStatus(parentToolCall.status) || - (parentToolCall.status === ToolCallStatus.success && - parentPreviewSession !== undefined && - parentPreviewSession.status !== 'complete')) - if (parentIdx !== undefined && parentToolCall && canReuseParentRow) { - state.toolMap.set(id, parentIdx) - parentToolCall.status = 'executing' - parentToolCall.result = undefined - ops.flush() - return - } - } - - const existingToolCall = state.toolMap.has(id) - ? state.blocks[state.toolMap.get(id)!]?.toolCall - : undefined - const isNewToolCall = !existingToolCall - if (isNewToolCall) { - ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) - state.toolMap.set(id, state.blocks.length) - const parentToolCallIdForBlock = ops.resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - state.blocks.push({ - type: 'tool_call', - toolCall: { - id, - name, - status: 'executing', - displayTitle, - params: args, - calledBy: scopedSubagent, - }, - ...(parentToolCallIdForBlock ? { parentToolCallId: parentToolCallIdForBlock } : {}), - ...spanIdentity, - timestamp: Date.now(), - }) - if (name === ReadTool.id || isResourceToolName(name)) { - if (args) state.toolArgsMap.set(id, args) - } - const pendingResult = state.pendingToolResults.get(id) - if (pendingResult !== undefined) { - state.pendingToolResults.delete(id) - applyToolResult(ctx, state.toolMap.get(id)!, id, pendingResult) - } - } else { - const idx = state.toolMap.get(id)! - const tc = state.blocks[idx].toolCall - if (tc) { - tc.name = name - if (displayTitle) tc.displayTitle = displayTitle - if (args) tc.params = args - } - } - ops.flush() - if (isWorkflowToolName(name) && !isPartial) { const shouldStartWorkflowTool = - !deps.options.suppressedWorkflowToolStartIds?.has(id) && - (isNewToolCall || - (existingToolCall?.status === ToolCallStatus.executing && !existingToolCall.result)) + !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && + node?.kind === 'tool' && + node.status === 'running' && + !node.result if (shouldStartWorkflowTool) { - deps.startClientWorkflowTool(id, name, args ?? {}) + const args = payload.arguments as Record | undefined + deps.startClientWorkflowTool(rawId, name, args ?? {}) } } + ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts index e0b9c3ff895..0ec7ddf433c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts @@ -9,3 +9,4 @@ export { type StreamLoopState, } from './stream-context' export { finalizeResidualToolCalls } from './stream-helpers' +export { applyTurnTerminal } from './turn-model' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts index 4d810f4fe60..67eb6ab1b23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts @@ -3,11 +3,23 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { MothershipStreamV1ErrorPayload } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { createStreamLoopContext } from './stream-context' import { makeStreamLoopDeps, ref } from './stream-test-helpers' +import { reduceEvent } from './turn-model' + +function textEnvelope(text: string): PersistedStreamEventEnvelope { + return { + v: 1, + seq: 1, + ts: '', + stream: { streamId: 's', cursor: '1' }, + type: 'text', + payload: { channel: 'assistant', text }, + } as unknown as PersistedStreamEventEnvelope +} describe('createStreamLoopContext', () => { describe('isStale', () => { @@ -82,7 +94,7 @@ describe('createStreamLoopContext', () => { }) describe('preserveExistingState reconnect hydration', () => { - it('rebuilds blocks, toolMap, toolArgsMap, subagentBySpanId and recovers the active subagent', () => { + it('rebuilds the model (tools and subagent lanes) from the persisted snapshot', () => { const blocks: ContentBlock[] = [ { type: 'text', content: 'hi' }, { @@ -103,16 +115,15 @@ describe('createStreamLoopContext', () => { streamingContentRef: ref('hi'), }) ) - expect(ctx.state.blocks).toHaveLength(3) - expect(ctx.state.runningText).toBe('hi') - expect(ctx.state.toolMap.get('tc-1')).toBe(1) - expect(ctx.state.toolArgsMap.get('tc-1')).toEqual({ path: '/a' }) - expect(ctx.state.subagentBySpanId.get('span-1')).toBe('file') - expect(ctx.state.activeSubagent).toBe('file') - expect(ctx.state.activeSubagentParentToolCallId).toBe('tc-1') + const tool = ctx.state.model.nodes.get('tc-1') + expect(tool?.kind).toBe('tool') + expect((tool as { status: string }).status).toBe('success') + const agent = ctx.state.model.nodes.get('span-1') + expect(agent?.kind).toBe('agent') + expect((agent as { agentId: string }).agentId).toBe('file') }) - it('stops recovering the active subagent at a subagent_end marker', () => { + it('rebuilds a closed subagent lane as terminal at a subagent_end marker', () => { const blocks: ContentBlock[] = [ { type: 'subagent', content: 'file', spanId: 'span-1' }, { type: 'subagent_end', spanId: 'span-1' }, @@ -123,7 +134,9 @@ describe('createStreamLoopContext', () => { streamingBlocksRef: ref(blocks), }) ) - expect(ctx.state.activeSubagent).toBeUndefined() + const agent = ctx.state.model.nodes.get('span-1') + expect(agent?.kind).toBe('agent') + expect((agent as { status: string }).status).not.toBe('running') }) it('does not clear the shared refs on a preserve-state stream', () => { @@ -146,7 +159,6 @@ describe('createStreamLoopContext', () => { const ctx = createStreamLoopContext( makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2), setPendingMessages }) ) - ctx.state.blocks.push({ type: 'text', content: 'x' }) ctx.ops.flush() expect(setPendingMessages).not.toHaveBeenCalled() }) @@ -156,8 +168,8 @@ describe('createStreamLoopContext', () => { const ctx = createStreamLoopContext( makeStreamLoopDeps({ chatIdRef: ref(undefined), setPendingMessages }) ) - ctx.state.runningText = 'hello' - ctx.state.blocks.push({ type: 'text', content: 'hello' }) + // flush serializes the model (the single source of truth) into the snapshot. + reduceEvent(ctx.state.model, textEnvelope('hello')) ctx.ops.flush() expect(setPendingMessages).toHaveBeenCalledTimes(1) const updater = setPendingMessages.mock.calls[0][0] as (prev: ChatMessage[]) => ChatMessage[] @@ -190,98 +202,4 @@ describe('createStreamLoopContext', () => { expect(setPendingMessages).not.toHaveBeenCalled() }) }) - - describe('block builders', () => { - it('ensureTextBlock coalesces consecutive same-scope text blocks', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - const a = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) - const b = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) - expect(a).toBe(b) - expect(ctx.state.blocks).toHaveLength(1) - }) - - it('ensureTextBlock starts a new block on a subagent-scope change and stamps the prior end', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - const main = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) - const sub = ctx.ops.ensureTextBlock('file', undefined, undefined, { spanId: 's1' }) - expect(sub).not.toBe(main) - expect(ctx.state.blocks).toHaveLength(2) - expect(main.endedAt).toBeTypeOf('number') - expect(sub.spanId).toBe('s1') - expect(sub.subagent).toBe('file') - }) - - it('ensureThinkingBlock uses subagent_thinking under a subagent', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - const tb = ctx.ops.ensureThinkingBlock('file', 'tc', undefined, {}) - expect(tb.type).toBe('subagent_thinking') - }) - - it('toEventMs falls back to a finite now on an invalid timestamp', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - const ms = ctx.ops.toEventMs('not-a-date') - expect(Number.isFinite(ms)).toBe(true) - }) - - it('stampBlockEnd never closes a subagent header (prevents concurrent-lane flicker)', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - - // A subagent header must stay open when a generic block boundary fires - // (e.g. the next sibling subagent starts, or this lane's first content - // arrives). endedAt is set only by the real span-end handler. - const header: ContentBlock = { type: 'subagent', content: 'research', spanId: 's1' } - ctx.ops.stampBlockEnd(header) - expect(header.endedAt).toBeUndefined() - - // Other block types still get their endedAt stamped as before. - const text: ContentBlock = { type: 'text', content: 'hi' } - ctx.ops.stampBlockEnd(text) - expect(text.endedAt).toBeTypeOf('number') - }) - - it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId (scope-only, no active fallback)', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - ctx.state.subagentBySpanId.set('s1', 'spanAgent') - ctx.state.subagentByParentToolCallId.set('p1', 'parentAgent') - ctx.state.activeSubagent = 'activeAgent' - expect(ctx.ops.resolveScopedSubagent('explicit', 'p1', 's1')).toBe('explicit') - expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', 's1')).toBe('spanAgent') - expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', undefined)).toBe('parentAgent') - // No scope match → undefined (the legacy activeSubagent fallback was - // removed so a concurrent sibling can never be mis-attributed). - expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBeUndefined() - }) - - it('rebuilds every open subagent lane on reconnect, skipping closed ones', () => { - const blocks: ContentBlock[] = [ - { type: 'subagent', content: 'research', spanId: 'span-a', parentToolCallId: 'tc-a' }, - { type: 'subagent', content: 'deploy', spanId: 'span-b', parentToolCallId: 'tc-b' }, - // span-b closed via marker; span-a stays open. - { type: 'subagent_end', spanId: 'span-b', parentToolCallId: 'tc-b' }, - ] - const ctx = createStreamLoopContext( - makeStreamLoopDeps({ - options: { preserveExistingState: true }, - streamingBlocksRef: ref(blocks), - }) - ) - expect(ctx.state.subagentBySpanId.get('span-a')).toBe('research') - expect(ctx.state.subagentBySpanId.has('span-b')).toBe(false) - expect(ctx.state.subagentByParentToolCallId.get('tc-a')).toBe('research') - expect(ctx.state.subagentByParentToolCallId.has('tc-b')).toBe(false) - }) - - it('buildInlineErrorTag includes the message, code and provider', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - const tag = ctx.ops.buildInlineErrorTag({ - message: 'boom', - code: 'E1', - provider: 'openai', - } as unknown as MothershipStreamV1ErrorPayload) - expect(tag).toContain('mothership-error') - expect(tag).toContain('boom') - expect(tag).toContain('E1') - expect(tag).toContain('openai') - }) - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 64b0daa654c..b49871bfbdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -3,10 +3,17 @@ import type { QueryClient } from '@tanstack/react-query' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction' import { captureRevealedSimKeys } from '@/lib/copilot/chat/sim-key-redaction' -import type { MothershipStreamV1ErrorPayload } from '@/lib/copilot/generated/mothership-stream-v1' import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' -import type { ToolResultPhasePayload } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' +import { + createTurnModel, + type TurnModel, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' +import { + contentBlocksToModel, + modelMainText, + modelToContentBlocks, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' import type { ChatMessage, ContentBlock, @@ -30,34 +37,24 @@ export interface StreamLoopOptions { } export interface StreamLoopState { - blocks: ContentBlock[] - toolMap: Map - toolArgsMap: Map> - subagentByParentToolCallId: Map - subagentBySpanId: Map - pendingToolResults: Map - runningText: string - lastContentSource: 'main' | 'subagent' | null + /** + * The normalized turn model — the single source of truth for streamed state. + * `reduceEvent` folds every event into it; `flush` serializes it to the + * persisted/rendered `contentBlocks` shape. The handlers carry no block state. + */ + model: TurnModel streamRequestId: string | undefined - activeSubagent: string | undefined - activeSubagentParentToolCallId: string | undefined - activeCompactionId: string | undefined sawStreamError: boolean sawCompleteEvent: boolean scheduledTextFlushFrame: number | null } export interface StreamEventScope { - scopedSubagent: string | undefined scopedParentToolCallId: string | undefined scopedAgentId: string | undefined scopedSpanId: string | undefined - scopedParentSpanId: string | undefined - spanIdentity: { spanId?: string; parentSpanId?: string } } -type SpanIdentity = { spanId?: string; parentSpanId?: string } - export interface StreamLoopDeps { workspaceId: string queryClient: QueryClient @@ -136,36 +133,6 @@ export interface StreamLoopDeps { export interface StreamLoopOps { isStale: () => boolean - toEventMs: (ts: string | undefined) => number - stampBlockEnd: (block: ContentBlock | undefined, ts?: string) => void - ensureTextBlock: ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string, - identity?: SpanIdentity - ) => ContentBlock - ensureThinkingBlock: ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string, - identity?: SpanIdentity - ) => ContentBlock - resolveScopedSubagent: ( - agentId: string | undefined, - parentToolCallId: string | undefined, - spanId?: string - ) => string | undefined - resolveParentForSubagentBlock: ( - subagent: string | undefined, - scopedParent: string | undefined - ) => string | undefined - appendInlineErrorTag: ( - tag: string, - subagentName?: string, - parentToolCallId?: string, - ts?: string - ) => void - buildInlineErrorTag: (payload: MothershipStreamV1ErrorPayload) => string flush: () => void flushText: () => void } @@ -189,18 +156,12 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext const preserveState = deps.options.preserveExistingState === true const state: StreamLoopState = { - blocks: preserveState ? [...deps.streamingBlocksRef.current] : [], - toolMap: new Map(), - toolArgsMap: new Map>(), - subagentByParentToolCallId: new Map(), - subagentBySpanId: new Map(), - pendingToolResults: new Map(), - runningText: preserveState ? deps.streamingContentRef.current || '' : '', - lastContentSource: null, + // On a reconnect that preserves state, rebuild the model from the last + // serialized snapshot so live events fold into the identical model. + model: preserveState + ? contentBlocksToModel(deps.streamingBlocksRef.current) + : createTurnModel(), streamRequestId: undefined, - activeSubagent: undefined, - activeSubagentParentToolCallId: undefined, - activeCompactionId: undefined, sawStreamError: false, sawCompleteEvent: false, scheduledTextFlushFrame: null, @@ -210,163 +171,29 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) || deps.options.shouldContinue?.() === false - if (preserveState) { - for (let i = 0; i < state.blocks.length; i++) { - const tc = state.blocks[i].toolCall - if (tc) { - state.toolMap.set(tc.id, i) - if (tc.params) state.toolArgsMap.set(tc.id, tc.params) - } - } - // Rebuild ALL open subagent lanes (not just the most recent one) so that a - // reconnect mid-flight with multiple concurrent subagents rehydrates every - // lane. A lane is closed when its `subagent` start block has an endedAt OR a - // matching `subagent_end` marker exists (the live path stamps endedAt and - // pushes subagent_end; the persisted backend path stamps endedAt only). - const endedSpanIds = new Set() - const endedParents = new Set() - for (const block of state.blocks) { - if (block.type === 'subagent_end') { - if (block.spanId) endedSpanIds.add(block.spanId) - if (block.parentToolCallId) endedParents.add(block.parentToolCallId) - } - } - for (const block of state.blocks) { - if (block.type !== 'subagent' || !block.content || block.endedAt !== undefined) continue - if (block.spanId && endedSpanIds.has(block.spanId)) continue - if (block.parentToolCallId && endedParents.has(block.parentToolCallId)) continue - if (block.spanId) state.subagentBySpanId.set(block.spanId, block.content) - if (block.parentToolCallId) { - state.subagentByParentToolCallId.set(block.parentToolCallId, block.content) - } - // Keep a best-effort single pointer for legacy (no-spanId) dedup only; - // routing no longer depends on it. - state.activeSubagent = block.content - state.activeSubagentParentToolCallId = block.parentToolCallId - } - } else if (!isStale()) { + if (!preserveState && !isStale()) { deps.streamingContentRef.current = '' deps.streamingBlocksRef.current = [] } - const toEventMs = (ts: string | undefined): number => { - if (ts) { - const parsed = Date.parse(ts) - if (Number.isFinite(parsed)) return parsed - } - return Date.now() - } - - const stampBlockEnd = (block: ContentBlock | undefined, ts?: string) => { - // Never stamp a subagent header here. Its endedAt is the renderer's - // "group closed" signal (parseBlocksWithSpanTree), set explicitly only when - // the subagent's span actually ends (the span-end handler and the backend - // both set it directly). Stamping it as a generic block boundary — when the - // next sibling subagent starts, or when this lane's first content arrives — - // would close + prune concurrent subagents mid-stream, making them all flash - // in, vanish to one, then reappear one-by-one as content trickles in. - if (!block || block.type === 'subagent') return - if (block.endedAt === undefined) block.endedAt = toEventMs(ts) - } - - const ensureTextBlock = ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string, - identity?: SpanIdentity - ): ContentBlock => { - const last = state.blocks[state.blocks.length - 1] - if ( - last?.type === 'text' && - last.subagent === subagentName && - last.parentToolCallId === parentToolCallId && - last.spanId === identity?.spanId - ) { - return last - } - stampBlockEnd(last, ts) - const b: ContentBlock = { type: 'text', content: '', timestamp: toEventMs(ts) } - if (subagentName) b.subagent = subagentName - if (parentToolCallId) b.parentToolCallId = parentToolCallId - if (identity?.spanId) b.spanId = identity.spanId - if (identity?.parentSpanId) b.parentSpanId = identity.parentSpanId - state.blocks.push(b) - return b - } - - const ensureThinkingBlock = ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string, - identity?: SpanIdentity - ): ContentBlock => { - const targetType = subagentName ? 'subagent_thinking' : 'thinking' - const last = state.blocks[state.blocks.length - 1] - if ( - last?.type === targetType && - last.subagent === subagentName && - last.parentToolCallId === parentToolCallId && - last.spanId === identity?.spanId - ) { - return last - } - stampBlockEnd(last, ts) - const b: ContentBlock = { type: targetType, content: '', timestamp: toEventMs(ts) } - if (subagentName) b.subagent = subagentName - if (parentToolCallId) b.parentToolCallId = parentToolCallId - if (identity?.spanId) b.spanId = identity.spanId - if (identity?.parentSpanId) b.parentSpanId = identity.parentSpanId - state.blocks.push(b) - return b - } - - const resolveScopedSubagent = ( - agentId: string | undefined, - parentToolCallId: string | undefined, - spanId?: string - ): string | undefined => { - // Scope-only: resolve by the event's own identity. The legacy - // `state.activeSubagent` fallback was removed — with concurrent subagents it - // points at whichever started most recently and would mis-attribute an - // interleaved event from a different lane. Well-formed subagent events carry - // agentId (and spanId), so this resolves deterministically; anything else is - // treated as main-lane rather than guessed. - if (agentId) return agentId - if (spanId) { - const scoped = state.subagentBySpanId.get(spanId) - if (scoped) return scoped - } - if (parentToolCallId) { - const scoped = state.subagentByParentToolCallId.get(parentToolCallId) - if (scoped) return scoped - } - return undefined - } - - const resolveParentForSubagentBlock = ( - subagent: string | undefined, - scopedParent: string | undefined - ): string | undefined => { - // Scope-only: a subagent block's parent comes from the event's own scope. - // The previous "first parent whose name matches" scan was ambiguous when two - // concurrent subagents share an agent name, so it was removed. - if (!subagent) return undefined - return scopedParent - } - const flush = () => { if (isStale()) return - deps.streamingBlocksRef.current = [...state.blocks] + // The model is authoritative: serialize it to the persisted/rendered block + // shape and main-lane content for every snapshot write. + const modelBlocks = modelToContentBlocks(state.model) + const modelContent = modelMainText(state.model) + deps.streamingBlocksRef.current = modelBlocks + deps.streamingContentRef.current = modelContent captureRevealedSimKeys( deps.revealedSimKeysRef.current, [deps.assistantId, state.streamRequestId], - state.runningText + modelContent ) const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current if (!activeChatId) { const snapshot: Partial = { - content: state.runningText, - contentBlocks: [...state.blocks], + content: modelContent, + contentBlocks: modelBlocks, } if (state.streamRequestId) snapshot.requestId = state.streamRequestId deps.setPendingMessages((prev) => { @@ -387,8 +214,8 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext const assistantMessage = deps.buildAssistantSnapshotMessage({ id: deps.assistantId, - content: state.runningText, - contentBlocks: state.blocks, + content: modelContent, + contentBlocks: modelBlocks, ...(state.streamRequestId ? { requestId: state.streamRequestId } : {}), }) deps.upsertMothershipChatHistory(activeChatId, (current) => { @@ -427,46 +254,8 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext }) } - const appendInlineErrorTag = ( - tag: string, - subagentName?: string, - parentToolCallId?: string, - ts?: string - ) => { - if (state.runningText.includes(tag)) return - const tb = ensureTextBlock(subagentName, parentToolCallId, ts) - const prefix = state.runningText.length > 0 && !state.runningText.endsWith('\n') ? '\n' : '' - tb.content = `${tb.content ?? ''}${prefix}${tag}` - state.runningText += `${prefix}${tag}` - deps.streamingContentRef.current = state.runningText - flush() - } - - const buildInlineErrorTag = (payload: MothershipStreamV1ErrorPayload) => { - 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 `${JSON.stringify({ - message, - ...(code ? { code } : {}), - ...(provider ? { provider } : {}), - })}` - } - const ops: StreamLoopOps = { isStale, - toEventMs, - stampBlockEnd, - ensureTextBlock, - ensureThinkingBlock, - resolveScopedSubagent, - resolveParentForSubagentBlock, - appendInlineErrorTag, - buildInlineErrorTag, flush, flushText, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 9209614ec4f..9560560e1b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -1,7 +1,5 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' -import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' -import type { MothershipStreamV1ToolUI } from '@/lib/copilot/generated/mothership-stream-v1' import { CrawlWebsite, CreateFolder, @@ -69,61 +67,43 @@ export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ export type StreamPayload = Record -export type StreamToolUI = { - hidden?: boolean - title?: string - clientExecutable?: boolean -} - -export type ToolResultPhasePayload = { - output?: unknown - status?: string - error?: unknown - success?: boolean -} - export function asPayloadRecord(value: unknown): StreamPayload | undefined { return isRecordLike(value) ? value : undefined } -export function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined { - if (!ui) { - return undefined - } - - const title = - typeof ui.title === 'string' - ? ui.title - : typeof ui.phaseLabel === 'string' - ? ui.phaseLabel - : undefined - - return { - ...(typeof ui.hidden === 'boolean' ? { hidden: ui.hidden } : {}), - ...(title ? { title } : {}), - ...(typeof ui.clientExecutable === 'boolean' ? { clientExecutable: ui.clientExecutable } : {}), - } -} - +/** + * Settles any tool row still `executing` at a turn terminal by propagating the + * turn's outcome — the deterministic replacement for the old `interrupted` + * invention. A clean `complete` means the turn succeeded, so a straggler is + * settled `success` (with explicit tool/span terminals from the backend there + * are normally none); a stop settles `cancelled`; an error settles `error`. + */ export function finalizeResidualToolCalls( blocks: ContentBlock[], turnTerminal: 'complete' | 'cancelled' | 'error' ): void { const endedAt = Date.now() + const propagated = + turnTerminal === 'cancelled' + ? ToolCallStatus.cancelled + : turnTerminal === 'error' + ? ToolCallStatus.error + : ToolCallStatus.success for (const block of blocks) { + // Close any still-open subagent lane at the turn terminal so its group + // resolves deterministically even when the backend cut off before a + // `span end` (abort/disconnect). The projection treats a stamped `endedAt` + // as a closed group, so the delegating spinner clears without any + // transport-based gating. + if (block.type === 'subagent' && block.endedAt === undefined) { + block.endedAt = endedAt + continue + } const tc = block.toolCall if (!tc || tc.status !== ToolCallStatus.executing) continue - if (turnTerminal === 'cancelled') { - tc.status = ToolCallStatus.cancelled + tc.status = propagated + if (propagated === ToolCallStatus.cancelled) { tc.displayTitle = 'Stopped by user' - } else if (turnTerminal === 'error') { - tc.status = ToolCallStatus.error - } else { - tc.status = ToolCallStatus.interrupted - logger.warn('Tool call unresolved at turn completion', { - toolCallId: tc.id, - toolName: tc.name, - }) } if (block.endedAt === undefined) { block.endedAt = endedAt @@ -131,27 +111,6 @@ export function finalizeResidualToolCalls( } } -export function isTerminalToolCallStatus(status: ToolCallStatus): boolean { - return ( - status === ToolCallStatus.success || - status === ToolCallStatus.error || - status === ToolCallStatus.cancelled || - status === ToolCallStatus.skipped || - status === ToolCallStatus.rejected || - status === ToolCallStatus.interrupted - ) -} - -export function resolveLiveToolStatus( - payload: Partial<{ - status: string - success: boolean - output: unknown - }> -): ToolCallStatus { - return resolveStreamToolOutcome(payload) as ToolCallStatus -} - function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined { const lastSegment = segments[segments.length - 1] if (!lastSegment) return undefined diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts new file mode 100644 index 00000000000..ee056511931 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts @@ -0,0 +1,365 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { + type AgentNode, + applyTurnTerminal, + createTurnModel, + reduceEvent, + type ToolNode, + type TurnModel, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' +import { + contentBlocksToModel, + modelToContentBlocks, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' + +interface Scope { + lane: 'subagent' + spanId?: string + parentSpanId?: string + parentToolCallId?: string + agentId?: string +} + +function env(seq: number, type: string, payload: Record, scope?: Scope) { + return { + v: 1, + seq, + // Real ts so tsMs === seq, exercising the wall-clock timing path. + ts: new Date(seq).toISOString(), + stream: { streamId: 's1', cursor: String(seq) }, + type, + payload, + ...(scope ? { scope } : {}), + } as unknown as PersistedStreamEventEnvelope +} + +function build(events: PersistedStreamEventEnvelope[]): TurnModel { + const m = createTurnModel() + for (const e of events) reduceEvent(m, e) + return m +} + +// A main-agent file delegation: trigger tool (main lane), subagent span, inner +// workspace_file, span end, delegation result. +function fileDelegationEvents(): PersistedStreamEventEnvelope[] { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentSpanId: 'main', + parentToolCallId: 'tc-file', + agentId: 'file', + } + return [ + env(1, 'text', { channel: 'assistant', text: 'Writing the file.' }), + env(2, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }), + env( + 3, + 'span', + { kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } }, + sub + ), + env( + 4, + 'tool', + { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { lane: 'subagent', spanId: 'S1' } + ), + env( + 5, + 'tool', + { phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true }, + { lane: 'subagent', spanId: 'S1' } + ), + env( + 6, + 'span', + { kind: 'subagent', event: 'end', agent: 'file', data: {} }, + { lane: 'subagent', spanId: 'S1' } + ), + env(7, 'tool', { phase: 'result', toolCallId: 'tc-file', toolName: 'file', success: true }), + ] +} + +function blocksByType(blocks: ReturnType, type: string) { + return blocks.filter((b) => b.type === type) +} + +describe('modelToContentBlocks', () => { + it('emits main-lane blocks without spanId and subagent-lane blocks with spanId', () => { + const blocks = modelToContentBlocks(build(fileDelegationEvents())) + + const mainText = blocks.find((b) => b.type === 'text') + expect(mainText?.spanId).toBeUndefined() + + const trigger = blocksByType(blocks, 'tool_call').find((b) => b.toolCall?.name === 'file') + expect(trigger?.spanId).toBeUndefined() + expect(trigger?.toolCall?.status).toBe('success') + + const innerTool = blocksByType(blocks, 'tool_call').find( + (b) => b.toolCall?.name === 'workspace_file' + ) + expect(innerTool?.spanId).toBe('S1') + expect(innerTool?.toolCall?.calledBy).toBe('file') + expect(innerTool?.toolCall?.status).toBe('success') + + const subagent = blocks.find((b) => b.type === 'subagent') + expect(subagent?.spanId).toBe('S1') + expect(subagent?.parentSpanId).toBe('main') + expect(subagent?.parentToolCallId).toBe('tc-file') + }) + + it('orders blocks by wire seq and appends new content without reordering existing blocks', () => { + const m = createTurnModel() + reduceEvent(m, env(1, 'text', { channel: 'assistant', text: 'one' })) + reduceEvent(m, env(2, 'tool', { phase: 'call', toolCallId: 't1', toolName: 'search' })) + const snap1 = modelToContentBlocks(m) + expect(snap1.map((b) => b.type)).toEqual(['text', 'tool_call']) + + // Later events arrive; the tool settles and new text starts. + reduceEvent( + m, + env(3, 'tool', { phase: 'result', toolCallId: 't1', toolName: 'search', success: true }) + ) + reduceEvent(m, env(4, 'text', { channel: 'assistant', text: 'two' })) + const snap2 = modelToContentBlocks(m) + + // Existing blocks keep their position (snap1 is a prefix of snap2); new text appends. + expect(snap2.map((b) => b.type)).toEqual(['text', 'tool_call', 'text']) + expect(snap2[1].toolCall?.id).toBe('t1') + expect(snap2[0].content).toBe('one') + }) + + it('places subagent_end at its end seq (after the lane work), never reordering siblings', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentToolCallId: 'tc-file', + agentId: 'file', + } + const blocks = modelToContentBlocks( + build([ + env(1, 'text', { channel: 'assistant', text: 'before' }), + env(2, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }), + env( + 3, + 'span', + { kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } }, + sub + ), + env( + 4, + 'tool', + { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { lane: 'subagent', spanId: 'S1' } + ), + env( + 5, + 'span', + { kind: 'subagent', event: 'end', agent: 'file', data: {} }, + { lane: 'subagent', spanId: 'S1' } + ), + env(6, 'text', { channel: 'assistant', text: 'after' }), + ]) + ) + const types = blocks.map((b) => b.type) + const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file') + const endIdx = types.indexOf('subagent_end') + const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after') + // subagent_end sits after the inner work and before the trailing main text — no sibling jumps. + expect(endIdx).toBeGreaterThan(innerIdx) + expect(afterIdx).toBeGreaterThan(endIdx) + }) + + it('preserves thinking timing across a model -> blocks -> model reconnect round-trip', () => { + const m1 = build([ + env(1, 'text', { channel: 'thinking', text: 'pondering' }), + env(2, 'text', { channel: 'assistant', text: 'the answer' }), + ]) + const blocks1 = modelToContentBlocks(m1) + const blocks2 = modelToContentBlocks(contentBlocksToModel(blocks1)) + const t1 = blocks1.find((b) => b.type === 'thinking') + const t2 = blocks2.find((b) => b.type === 'thinking') + expect(t1?.timestamp).toBe(1) + expect(t1?.endedAt).toBe(2) + // Reconnect rebuild must not reset timing to seq/undefined. + expect(t2?.timestamp).toBe(t1?.timestamp) + expect(t2?.endedAt).toBe(t1?.endedAt) + }) + + it('emits subagent_end for a straggler lane closed by a model terminal (no span end)', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentToolCallId: 'tc-file', + agentId: 'file', + } + const m = build([ + env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }), + env( + 2, + 'span', + { kind: 'subagent', event: 'start', agent: 'file', data: { tool_call_id: 'tc-file' } }, + sub + ), + env( + 3, + 'tool', + { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { lane: 'subagent', spanId: 'S1' } + ), + ]) + applyTurnTerminal(m, 'error') + const blocks = modelToContentBlocks(m) + expect(blocks.some((b) => b.type === 'subagent_end' && b.spanId === 'S1')).toBe(true) + }) + + it('skips per-call hidden tool nodes but keeps them in the model for side effects', () => { + const m = build([ + env(1, 'tool', { + phase: 'call', + toolCallId: 'h-1', + toolName: 'secret_tool', + ui: { hidden: true }, + }), + env(2, 'tool', { + phase: 'result', + toolCallId: 'h-1', + toolName: 'secret_tool', + success: true, + }), + ]) + expect(m.nodes.has('h-1')).toBe(true) + expect(blocksByType(modelToContentBlocks(m), 'tool_call')).toHaveLength(0) + }) + + it('resolves a tool display title from its arguments', () => { + const blocks = modelToContentBlocks( + build([ + env(1, 'tool', { + phase: 'call', + toolCallId: 'wf', + toolName: 'workspace_file', + arguments: { operation: 'create', title: 'My Doc' }, + }), + ]) + ) + const tool = blocksByType(blocks, 'tool_call').find((b) => b.toolCall?.id === 'wf') + expect(tool?.toolCall?.displayTitle).toBeTruthy() + }) + + it('emits a paired subagent_end at the run end seq, ordered after the inner work', () => { + const blocks = modelToContentBlocks(build(fileDelegationEvents())) + const startIdx = blocks.findIndex((b) => b.type === 'subagent') + const innerIdx = blocks.findIndex( + (b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file' + ) + const endIdx = blocks.findIndex((b) => b.type === 'subagent_end') + expect(startIdx).toBeGreaterThanOrEqual(0) + expect(endIdx).toBeGreaterThan(innerIdx) + expect(innerIdx).toBeGreaterThan(startIdx) + }) + + it('omits subagent_end while the run is still open', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentSpanId: 'main', + parentToolCallId: 'tc-file', + agentId: 'file', + } + const blocks = modelToContentBlocks( + build([ + env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }), + env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, sub), + ]) + ) + expect(blocksByType(blocks, 'subagent_end')).toHaveLength(0) + expect(blocksByType(blocks, 'subagent')).toHaveLength(1) + }) +}) + +describe('contentBlocksToModel round-trip', () => { + function tool(model: TurnModel, id: string): ToolNode { + return model.nodes.get(id) as ToolNode + } + function agent(model: TurnModel, spanId: string): AgentNode { + return model.nodes.get(spanId) as AgentNode + } + + it('rebuilds tool and agent statuses and nesting from serialized blocks', () => { + const original = build(fileDelegationEvents()) + const rebuilt = contentBlocksToModel(modelToContentBlocks(original)) + + expect(tool(rebuilt, 'tc-file').status).toBe('success') + expect(tool(rebuilt, 'wf-1').status).toBe('success') + expect(tool(rebuilt, 'wf-1').spanId).toBe('S1') + expect(agent(rebuilt, 'S1').status).toBe('success') + expect(agent(rebuilt, 'S1').parentSpanId).toBe('main') + expect(agent(rebuilt, 'S1').triggerToolCallId).toBe('tc-file') + }) + + it('preserves a running tool and an open subagent across the round-trip', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentSpanId: 'main', + parentToolCallId: 'tc-file', + agentId: 'file', + } + const original = build([ + env(1, 'tool', { phase: 'call', toolCallId: 'tc-file', toolName: 'file' }), + env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, sub), + env( + 3, + 'tool', + { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { lane: 'subagent', spanId: 'S1' } + ), + ]) + const rebuilt = contentBlocksToModel(modelToContentBlocks(original)) + expect(tool(rebuilt, 'wf-1').status).toBe('running') + expect(agent(rebuilt, 'S1').status).toBe('running') + }) + + it('round-trips parallel same-name subagents on distinct spans', () => { + const subA: Scope = { + lane: 'subagent', + spanId: 'SA', + parentSpanId: 'main', + parentToolCallId: 'tc-a', + agentId: 'file', + } + const subB: Scope = { + lane: 'subagent', + spanId: 'SB', + parentSpanId: 'main', + parentToolCallId: 'tc-b', + agentId: 'file', + } + const original = build([ + env(1, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, subA), + env(2, 'span', { kind: 'subagent', event: 'start', agent: 'file', data: {} }, subB), + env( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'file', data: {} }, + { lane: 'subagent', spanId: 'SA' } + ), + env( + 4, + 'span', + { kind: 'subagent', event: 'end', agent: 'file', data: {} }, + { lane: 'subagent', spanId: 'SB' } + ), + ]) + const rebuilt = contentBlocksToModel(modelToContentBlocks(original)) + expect(agent(rebuilt, 'SA').triggerToolCallId).toBe('tc-a') + expect(agent(rebuilt, 'SB').triggerToolCallId).toBe('tc-b') + expect(agent(rebuilt, 'SA').status).toBe('success') + expect(agent(rebuilt, 'SB').status).toBe('success') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts new file mode 100644 index 00000000000..44f70c995c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -0,0 +1,336 @@ +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { + resolveStreamingToolDisplayTitle, + resolveToolDisplayTitle, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' +import { + type AgentNode, + createTurnModel, + MAIN_SPAN, + type NodeStatus, + reduceEvent, + type ToolNode, + type TurnModel, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +/** + * Serialization bridge between the normalized {@link TurnModel} (the streaming + * source of truth) and the persisted/rendered `ContentBlock[]` shape. The model + * is authoritative during streaming; `flush` serializes it to blocks for the + * React-Query/pending snapshot and the DB, and the renderer keeps projecting + * blocks via the existing `parseBlocks`. `contentBlocksToModel` rebuilds the + * model from a persisted snapshot so a reconnect mid-stream continues into the + * exact same model. + */ + +function nodeToToolStatus(status: NodeStatus): ToolCallStatus { + if (status === 'running') return ToolCallStatus.executing + return status +} + +function toolStatusToNode(status: ToolCallStatus): NodeStatus { + if (status === ToolCallStatus.executing) return 'running' + if (status === ToolCallStatus.interrupted) return 'error' + return status +} + +/** + * Resolves a tool row's display title with the same precedence the live handler + * used: the streaming-args title wins while args stream, then the arg-derived + * title, then the explicit `ui.title`. + */ +function toolDisplayTitle(node: ToolNode): string | undefined { + const streamingTitle = node.streamingArgs + ? resolveStreamingToolDisplayTitle(node.name, node.streamingArgs) + : undefined + return streamingTitle ?? resolveToolDisplayTitle(node.name, node.args) ?? node.uiTitle +} + +interface SeqBlock { + seq: number + block: ContentBlock +} + +/** + * Serializes the model to ordered content blocks matching the live handler + * shapes: main-lane blocks carry no `spanId`; subagent-lane blocks carry + * `spanId`/`parentSpanId` (and `subagent` name for text). A terminated agent + * emits a paired `subagent_end` at its end seq so the projection closes the lane + * exactly as the live browser path did. + */ +export function modelToContentBlocks(model: TurnModel): ContentBlock[] { + const entries: SeqBlock[] = [] + + for (const id of model.order) { + const node = model.nodes.get(id) + if (!node) continue + const isSub = node.spanId !== MAIN_SPAN + const ownerAgent = isSub ? (model.nodes.get(node.spanId) as AgentNode | undefined) : undefined + const spanFields = isSub + ? { + spanId: node.spanId, + ...(ownerAgent ? { parentSpanId: ownerAgent.parentSpanId } : {}), + } + : {} + + if (node.kind === 'text') { + if (!node.text) continue + // Real wall-clock timing drives the thinking-duration UI ("Thought for Ns" + // + the 3s active-suppression); fall back to seq when ts was unavailable. + const timing = { + timestamp: node.startedAtMs ?? node.seq, + ...(node.endedAtMs !== undefined ? { endedAt: node.endedAtMs } : {}), + } + if (node.channel === 'thinking') { + entries.push({ + seq: node.seq, + block: isSub + ? { + type: 'subagent_thinking', + content: node.text, + ...(ownerAgent ? { subagent: ownerAgent.agentId } : {}), + ...spanFields, + ...timing, + } + : { type: 'thinking', content: node.text, ...timing }, + }) + } else { + entries.push({ + seq: node.seq, + block: isSub + ? { + type: 'text', + content: node.text, + ...(ownerAgent ? { subagent: ownerAgent.agentId } : {}), + ...spanFields, + ...timing, + } + : { type: 'text', content: node.text, ...timing }, + }) + } + continue + } + + if (node.kind === 'tool') { + // Per-call hidden tools are tracked for side effects but never rendered. + if (node.hidden) continue + const displayTitle = toolDisplayTitle(node) + entries.push({ + seq: node.seq, + block: { + type: 'tool_call', + toolCall: { + id: node.id, + name: node.name, + status: nodeToToolStatus(node.status), + ...(displayTitle ? { displayTitle } : {}), + ...(node.args ? { params: node.args } : {}), + ...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}), + ...(node.result + ? { + result: { + success: node.result.success, + ...(node.result.output !== undefined ? { output: node.result.output } : {}), + ...(node.result.error ? { error: node.result.error } : {}), + }, + } + : {}), + ...(isSub && ownerAgent ? { calledBy: ownerAgent.agentId } : {}), + }, + ...spanFields, + // Wall-clock when available (uniform with text); falls back to seq. + timestamp: node.startedAtMs ?? node.seq, + }, + }) + continue + } + + // Agent node -> a `subagent` open block, plus a `subagent_end` at end seq. + entries.push({ + seq: node.seq, + block: { + type: 'subagent', + content: node.agentId, + spanId: node.spanId, + parentSpanId: node.parentSpanId, + ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), + timestamp: node.startedAtMs ?? node.seq, + }, + }) + if (node.endSeq !== undefined) { + entries.push({ + seq: node.endSeq, + block: { + type: 'subagent_end', + spanId: node.spanId, + parentSpanId: node.parentSpanId, + ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), + timestamp: node.endSeq, + }, + }) + } + } + + entries.sort((a, b) => a.seq - b.seq) + return entries.map((e) => e.block) +} + +/** Returns the assistant-channel text of the main lane, in order (snapshot `content`). */ +export function modelMainText(model: TurnModel): string { + let text = '' + for (const id of model.order) { + const node = model.nodes.get(id) + if (node?.kind === 'text' && node.spanId === MAIN_SPAN && node.channel === 'assistant') { + text += node.text + } + } + return text +} + +/** + * Rebuilds a model from a persisted/live snapshot of content blocks. Used when a + * reconnect resumes a stream whose model is not in memory (page reload mid-turn): + * the snapshot is replayed as synthetic envelopes so subsequent live events fold + * into the identical model. Operates on the live, span-carrying block shape. + */ +export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { + const model = createTurnModel() + let seq = 0 + const synth = ( + type: string, + payload: Record, + scope?: Record, + tsMs?: number + ): PersistedStreamEventEnvelope => + ({ + v: 1, + seq: ++seq, + // Carry the persisted wall-clock so the rebuilt model keeps real timing + // (thinking duration / 3s suppression) across a reconnect rebuild. + ts: tsMs !== undefined ? new Date(tsMs).toISOString() : '', + stream: { streamId: '', cursor: String(seq) }, + type, + payload, + ...(scope ? { scope } : {}), + }) as unknown as PersistedStreamEventEnvelope + + const scopeFor = (block: ContentBlock): Record | undefined => + block.spanId + ? { + lane: 'subagent', + spanId: block.spanId, + ...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}), + ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), + ...(block.subagent ? { agentId: block.subagent } : {}), + } + : undefined + + for (const block of blocks) { + if (block.type === 'subagent') { + reduceEvent( + model, + synth( + 'span', + { + kind: 'subagent', + event: 'start', + agent: block.content, + data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}, + }, + scopeFor(block), + block.timestamp + ) + ) + if (block.endedAt !== undefined) { + reduceEvent( + model, + synth( + 'span', + { kind: 'subagent', event: 'end', agent: block.content, data: {} }, + scopeFor(block), + block.endedAt + ) + ) + } + continue + } + if (block.type === 'subagent_end') { + reduceEvent( + model, + synth('span', { kind: 'subagent', event: 'end', agent: '', data: {} }, scopeFor(block)) + ) + continue + } + if (block.type === 'tool_call' && block.toolCall) { + const tc = block.toolCall + reduceEvent( + model, + synth( + 'tool', + { + phase: 'call', + toolCallId: tc.id, + toolName: tc.name, + arguments: tc.params, + // Preserve a server-provided title that isn't derivable from args. + ...(tc.displayTitle ? { ui: { title: tc.displayTitle } } : {}), + }, + scopeFor(block), + block.timestamp + ) + ) + if (tc.status !== ToolCallStatus.executing) { + const node = toolStatusToNode(tc.status) + reduceEvent( + model, + synth( + 'tool', + { + phase: 'result', + toolCallId: tc.id, + toolName: tc.name, + success: node === 'success', + status: node, + output: tc.result?.output, + // Carry the failure message so a reloaded failed tool keeps it. + ...(tc.result?.error ? { error: tc.result.error } : {}), + }, + scopeFor(block) + ) + ) + } + continue + } + if (block.type === 'text' || block.type === 'subagent_text') { + if (block.content) { + reduceEvent( + model, + synth( + 'text', + { channel: 'assistant', text: block.content }, + scopeFor(block), + block.timestamp + ) + ) + } + continue + } + if (block.type === 'thinking' || block.type === 'subagent_thinking') { + if (block.content) { + reduceEvent( + model, + synth( + 'text', + { channel: 'thinking', text: block.content }, + scopeFor(block), + block.timestamp + ) + ) + } + } + } + + return model +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts new file mode 100644 index 00000000000..667c3e0bb17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -0,0 +1,468 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { + type AgentNode, + applyTurnTerminal, + createTurnModel, + MAIN_SPAN, + reduceEvent, + type TextNode, + type ToolNode, + type TurnModel, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' + +interface Scope { + lane: 'subagent' + spanId?: string + parentSpanId?: string + parentToolCallId?: string + agentId?: string +} + +function envelope( + seq: number, + type: string, + payload: Record, + scope?: Scope +): PersistedStreamEventEnvelope { + return { + v: 1, + seq, + ts: new Date(seq).toISOString(), + stream: { streamId: 's1', cursor: String(seq) }, + type, + payload, + ...(scope ? { scope } : {}), + } as unknown as PersistedStreamEventEnvelope +} + +function toolCall(seq: number, id: string, name: string, scope?: Scope) { + return envelope(seq, 'tool', { phase: 'call', toolCallId: id, toolName: name }, scope) +} + +function toolResult(seq: number, id: string, success: boolean, status?: string, scope?: Scope) { + return envelope( + seq, + 'tool', + { phase: 'result', toolCallId: id, toolName: 'x', success, ...(status ? { status } : {}) }, + scope + ) +} + +function spanStart( + seq: number, + spanId: string, + agent: string, + parentToolCallId?: string, + parentSpanId = MAIN_SPAN +) { + return envelope( + seq, + 'span', + { + kind: 'subagent', + event: 'start', + agent, + data: parentToolCallId ? { tool_call_id: parentToolCallId } : {}, + }, + { + lane: 'subagent', + spanId, + parentSpanId, + ...(parentToolCallId ? { parentToolCallId } : {}), + agentId: agent, + } + ) +} + +function spanEnd( + seq: number, + spanId: string, + agent: string, + opts?: { error?: string; pending?: boolean } +) { + return envelope( + seq, + 'span', + { + kind: 'subagent', + event: 'end', + agent, + data: { + ...(opts?.error ? { error: opts.error } : {}), + ...(opts?.pending ? { pending: true } : {}), + }, + }, + { lane: 'subagent', spanId, agentId: agent } + ) +} + +function textEvent(seq: number, channel: 'assistant' | 'thinking', text: string, scope?: Scope) { + return envelope(seq, 'text', { channel, text }, scope) +} + +function complete(seq: number, status: 'complete' | 'cancelled' | 'error' = 'complete') { + return envelope(seq, 'complete', { status }) +} + +function apply(events: PersistedStreamEventEnvelope[], model = createTurnModel()): TurnModel { + for (const e of events) reduceEvent(model, e) + return model +} + +function tool(model: TurnModel, id: string): ToolNode { + const node = model.nodes.get(id) + expect(node?.kind).toBe('tool') + return node as ToolNode +} + +function agent(model: TurnModel, spanId: string): AgentNode { + const node = model.nodes.get(spanId) + expect(node?.kind).toBe('agent') + return node as AgentNode +} + +describe('reduceEvent — tool lifecycle', () => { + it('runs a tool then settles it success on result', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true)]) + expect(tool(m, 'tc-1').status).toBe('success') + expect(tool(m, 'tc-1').result?.success).toBe(true) + }) + + it('settles a tool error on a failed result', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', false)]) + expect(tool(m, 'tc-1').status).toBe('error') + }) + + it('honors an explicit terminal status over the success boolean', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true, 'cancelled')]) + expect(tool(m, 'tc-1').status).toBe('cancelled') + }) + + it('accumulates streaming args across deltas', () => { + const m = apply([ + toolCall(1, 'tc-1', 'workspace_file'), + envelope(2, 'tool', { + phase: 'args_delta', + toolCallId: 'tc-1', + toolName: 'workspace_file', + argumentsDelta: '{"a":', + }), + envelope(3, 'tool', { + phase: 'args_delta', + toolCallId: 'tc-1', + toolName: 'workspace_file', + argumentsDelta: '1}', + }), + ]) + expect(tool(m, 'tc-1').streamingArgs).toBe('{"a":1}') + expect(tool(m, 'tc-1').status).toBe('running') + }) + + it('clears streamingArgs once the result settles the tool', () => { + const m = apply([ + toolCall(1, 'tc-1', 'workspace_file'), + envelope(2, 'tool', { + phase: 'args_delta', + toolCallId: 'tc-1', + toolName: 'workspace_file', + argumentsDelta: '{"operation":"create"', + }), + toolResult(3, 'tc-1', true), + ]) + expect(tool(m, 'tc-1').status).toBe('success') + expect(tool(m, 'tc-1').streamingArgs).toBeUndefined() + }) + + it('buffers a result that arrives before its call, then applies it', () => { + const m = apply([toolResult(1, 'tc-1', true), toolCall(2, 'tc-1', 'search')]) + expect(tool(m, 'tc-1').status).toBe('success') + }) + + it('preserves result.error when a result is buffered before its call', () => { + const m = apply([ + envelope(1, 'tool', { + phase: 'result', + toolCallId: 'tc-1', + toolName: 'search', + success: false, + error: 'boom', + }), + toolCall(2, 'tc-1', 'search'), + ]) + expect(tool(m, 'tc-1').status).toBe('error') + expect(tool(m, 'tc-1').result?.error).toBe('boom') + }) + + it('resolves output-based cancellation (user_cancelled) as cancelled, not error', () => { + const m = apply([ + toolCall(1, 'tc-1', 'search'), + envelope(2, 'tool', { + phase: 'result', + toolCallId: 'tc-1', + toolName: 'search', + success: false, + output: { reason: 'user_cancelled' }, + }), + ]) + expect(tool(m, 'tc-1').status).toBe('cancelled') + }) + + it('ignores preview phases (decoupled from tool status)', () => { + const m = apply([ + toolCall(1, 'tc-1', 'workspace_file'), + envelope(2, 'tool', { + previewPhase: 'file_preview_content', + toolCallId: 'tc-1', + toolName: 'workspace_file', + content: 'x', + contentMode: 'delta', + fileName: 'f', + previewVersion: 1, + }), + ]) + expect(tool(m, 'tc-1').status).toBe('running') + expect(m.order).toEqual(['tc-1']) + }) +}) + +describe('reduceEvent — subagent lifecycle', () => { + it('opens an agent run on span start and settles it on span end', () => { + const m = apply([spanStart(1, 'S1', 'file', 'tc-file'), spanEnd(2, 'S1', 'file')]) + expect(agent(m, 'S1').status).toBe('success') + expect(agent(m, 'S1').triggerToolCallId).toBe('tc-file') + expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN) + }) + + it('settles an agent error when span end carries an error', () => { + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + spanEnd(2, 'S1', 'file', { error: 'boom' }), + ]) + expect(agent(m, 'S1').status).toBe('error') + }) + + it('keeps an agent running on a pending-pause span end', () => { + const m = apply([ + spanStart(1, 'S1', 'deploy', 'tc-deploy'), + spanEnd(2, 'S1', 'deploy', { pending: true }), + ]) + expect(agent(m, 'S1').status).toBe('running') + }) + + it('nests a child run under its parent by parentSpanId', () => { + const m = apply([ + spanStart(1, 'S1', 'workflow', 'tc-wf'), + spanStart(2, 'S2', 'deploy', 'tc-deploy', 'S1'), + spanEnd(3, 'S2', 'deploy'), + spanEnd(4, 'S1', 'workflow'), + ]) + expect(agent(m, 'S2').parentSpanId).toBe('S1') + expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN) + expect(agent(m, 'S1').status).toBe('success') + expect(agent(m, 'S2').status).toBe('success') + }) + + it('keeps two parallel same-name runs independent (no agentId collision)', () => { + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-a'), + spanStart(2, 'S2', 'file', 'tc-b'), + toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), + toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }), + toolResult(5, 'wf-a', true), + spanEnd(6, 'S1', 'file'), + toolResult(7, 'wf-b', true), + spanEnd(8, 'S2', 'file'), + ]) + expect(agent(m, 'S1').triggerToolCallId).toBe('tc-a') + expect(agent(m, 'S2').triggerToolCallId).toBe('tc-b') + expect(tool(m, 'wf-a').spanId).toBe('S1') + expect(tool(m, 'wf-b').spanId).toBe('S2') + expect(agent(m, 'S1').status).toBe('success') + expect(agent(m, 'S2').status).toBe('success') + }) +}) + +describe('reduceEvent — text segmentation', () => { + it('records wall-clock start/end for a thinking segment from wire ts', () => { + // envelope() stamps ts = new Date(seq).toISOString(), so tsMs === seq here. + const m = apply([ + textEvent(1, 'thinking', 'pondering'), + textEvent(2, 'assistant', 'the answer'), + ]) + const thinking = [...m.nodes.values()].find( + (n) => n.kind === 'text' && n.channel === 'thinking' + ) as TextNode + expect(thinking.startedAtMs).toBe(1) + // The answer starting closes the thinking segment, bounding its duration. + expect(thinking.endedAtMs).toBe(2) + }) + + it('merges contiguous deltas and splits across a tool boundary', () => { + const m = apply([ + textEvent(1, 'assistant', 'Hello '), + textEvent(2, 'assistant', 'world'), + toolCall(3, 'tc-1', 'search'), + toolResult(4, 'tc-1', true), + textEvent(5, 'assistant', 'after'), + ]) + const texts = m.order.map((id) => m.nodes.get(id)).filter((n) => n?.kind === 'text') + expect(texts.map((t) => (t as { text: string }).text)).toEqual(['Hello world', 'after']) + }) +}) + +describe('reduceEvent — idempotency', () => { + it('is a no-op for an already-applied seq (reconnect replay over a populated model)', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', true)]) + const before = JSON.stringify([...m.nodes]) + reduceEvent(m, toolCall(1, 'tc-1', 'search')) + reduceEvent(m, toolResult(2, 'tc-1', true)) + expect(JSON.stringify([...m.nodes])).toBe(before) + expect(m.order).toEqual(['tc-1']) + }) + + it('rebuilds the identical model when replayed into a fresh model', () => { + const events = [ + spanStart(1, 'S1', 'file', 'tc-file'), + toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), + toolResult(3, 'wf', true), + spanEnd(4, 'S1', 'file'), + complete(5), + ] + const live = apply(events) + const replayed = apply(events, createTurnModel()) + expect([...replayed.nodes]).toEqual([...live.nodes]) + expect(replayed.order).toEqual(live.order) + expect(replayed.status).toBe(live.status) + }) +}) + +describe('reduceEvent — edit_content row merge', () => { + it('folds an edit_content write into its span workspace_file row', () => { + const sub: Scope = { lane: 'subagent', spanId: 'S1' } + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + toolCall(2, 'wf-1', 'workspace_file', sub), + toolResult(3, 'wf-1', true, undefined, sub), + toolCall(4, 'ec-1', 'edit_content', sub), + ]) + // No separate edit_content node; the workspace_file row reopened for the edit. + expect(m.nodes.has('ec-1')).toBe(false) + expect(tool(m, 'wf-1').status).toBe('running') + expect(m.toolAlias.get('ec-1')).toBe('wf-1') + }) + + it('settles the merged row on the edit_content result', () => { + const sub: Scope = { lane: 'subagent', spanId: 'S1' } + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + toolCall(2, 'wf-1', 'workspace_file', sub), + toolCall(3, 'ec-1', 'edit_content', sub), + toolResult(4, 'ec-1', true, undefined, sub), + ]) + expect(tool(m, 'wf-1').status).toBe('success') + expect(m.nodes.has('ec-1')).toBe(false) + }) + + it('folds an edit_content result that raced ahead of its call into the merged row', () => { + const sub: Scope = { lane: 'subagent', spanId: 'S1' } + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + toolCall(2, 'wf-1', 'workspace_file', sub), + // Result for edit_content arrives BEFORE its call (buffered under ec-1)... + toolResult(3, 'ec-1', true, undefined, sub), + // ...then the call lands and aliases ec-1 -> wf-1, draining the buffer. + toolCall(4, 'ec-1', 'edit_content', sub), + ]) + expect(tool(m, 'wf-1').status).toBe('success') + expect(tool(m, 'wf-1').result?.success).toBe(true) + expect(m.bufferedResults.has('ec-1')).toBe(false) + }) +}) + +describe('reduceEvent — error tag + compaction coverage', () => { + it('appends an inline mothership-error tag to the scoped lane text', () => { + const m = apply([ + textEvent(1, 'assistant', 'Working'), + envelope(2, 'error', { message: 'boom', code: 'E1', provider: 'openai' }), + ]) + const text = [...m.nodes.values()].find((n) => n.kind === 'text') as { text: string } + expect(text.text).toContain('') + expect(text.text).toContain('boom') + expect(text.text).toContain('E1') + }) + + it('does not duplicate an identical error tag', () => { + const m = apply([ + textEvent(1, 'assistant', 'Working'), + envelope(2, 'error', { message: 'boom' }), + envelope(3, 'error', { message: 'boom' }), + ]) + const text = [...m.nodes.values()].find((n) => n.kind === 'text') as { text: string } + const occurrences = text.text.split('').length - 1 + expect(occurrences).toBe(1) + }) + + it('opens and closes a compaction node with titles', () => { + const m = apply([ + envelope(1, 'run', { kind: 'compaction_start' }), + envelope(2, 'run', { kind: 'compaction_done' }), + ]) + const compaction = [...m.nodes.values()].find( + (n) => n.kind === 'tool' && n.name === 'context_compaction' + ) as ToolNode + expect(compaction.status).toBe('success') + expect(compaction.uiTitle).toBe('Compacted context') + }) +}) + +describe('turn-terminal propagation', () => { + it('settles stragglers as success on a clean complete (never interrupted)', () => { + const m = apply([ + toolCall(1, 'tc-1', 'search'), + spanStart(2, 'S1', 'file', 'tc-file'), + complete(3, 'complete'), + ]) + expect(m.status).toBe('complete') + expect(tool(m, 'tc-1').status).toBe('success') + expect(agent(m, 'S1').status).toBe('success') + for (const node of m.nodes.values()) { + expect(node.kind === 'text' || node.status).not.toBe('interrupted') + } + }) + + it('closes a straggler subagent lane (sets endSeq) so a model-driven terminal resolves the group', () => { + // A file subagent opened but no span end arrived (mid-stream error/disconnect). + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), + ]) + expect(agent(m, 'S1').endSeq).toBeUndefined() + applyTurnTerminal(m, 'error') + expect(agent(m, 'S1').status).toBe('error') + // endSeq must be stamped so the serializer emits subagent_end and the lane's + // delegating spinner resolves instead of spinning forever. + expect(agent(m, 'S1').endSeq).toBeDefined() + }) + + it('settles open nodes cancelled on a stop', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), complete(2, 'cancelled')]) + expect(m.status).toBe('cancelled') + expect(tool(m, 'tc-1').status).toBe('cancelled') + }) + + it('settles open nodes error on an errored turn', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), complete(2, 'error')]) + expect(m.status).toBe('error') + expect(tool(m, 'tc-1').status).toBe('error') + }) + + it('never reopens an already-terminal node', () => { + const m = apply([toolCall(1, 'tc-1', 'search'), toolResult(2, 'tc-1', false)]) + applyTurnTerminal(m, 'complete') + expect(tool(m, 'tc-1').status).toBe('error') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts new file mode 100644 index 00000000000..c832ee79f78 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -0,0 +1,579 @@ +import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1RunKind, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' + +/** + * The single deterministic model of one assistant turn, derived purely from the + * Go wire stream. Every tool and subagent is a {@link LifecycleNode} with one + * explicit terminal source, so rendering reads node status instead of inferring + * it from transport, preview sessions, or a turn-complete sweep. Parallel + * subagents are independent span lanes; nested subagents nest by `parentSpanId`. + */ + +/** The root span lane id Go stamps on main-agent (non-subagent) events. */ +export const MAIN_SPAN = 'main' + +/** + * Terminal-bearing status for a single node. `running` is the only + * non-terminal value; everything else is read from an explicit wire terminal + * (tool `result`, span `end`) or propagated from the turn terminal. + */ +export type NodeStatus = 'running' | 'success' | 'error' | 'cancelled' | 'skipped' | 'rejected' + +/** Turn-level status. Terminal values come from the wire `complete`/`error`. */ +export type TurnStatus = 'streaming' | 'complete' | 'error' | 'cancelled' + +export type TextChannel = 'assistant' | 'thinking' + +interface NodeBase { + /** Stable node id. Tools use `toolCallId`; agents use `spanId`; text/synthetic use a derived id. */ + id: string + /** The span lane this node belongs to (`MAIN_SPAN` for the main agent). */ + spanId: string + /** Arrival order key (wire `seq`), monotonic within a turn. */ + seq: number + /** + * Wall-clock (wire `ts`) the node opened. Serialized as the block `timestamp` + * so it always means epoch-ms (never the wire seq), driving duration UI and + * surviving the reconnect round-trip. Absent only when `ts` was unavailable. + */ + startedAtMs?: number +} + +export interface ToolNode extends NodeBase { + kind: 'tool' + name: string + status: NodeStatus + args?: Record + streamingArgs?: string + uiTitle?: string + /** Per-call `ui.hidden` flag — the node is tracked for side effects but not rendered. */ + hidden?: boolean + result?: { success: boolean; output?: unknown; error?: string } +} + +export interface AgentNode extends NodeBase { + kind: 'agent' + /** Span lane of the run that invoked this one (`MAIN_SPAN` for a direct child). */ + parentSpanId: string + /** Display id (e.g. `file`, `workflow`) — never a routing key (collides across siblings). */ + agentId: string + /** The outer delegation tool_use that triggered this run; links the trigger tool node. */ + triggerToolCallId?: string + status: NodeStatus + /** Wire seq at which the run terminated (span end), for ordering the close marker. */ + endSeq?: number +} + +export interface TextNode extends NodeBase { + kind: 'text' + channel: TextChannel + text: string + /** Wall-clock (wire `ts`) the segment was superseded by the next lane content. */ + endedAtMs?: number +} + +export type LifecycleNode = ToolNode | AgentNode | TextNode + +export interface TurnModel { + status: TurnStatus + /** All nodes by id. */ + nodes: Map + /** Node ids in arrival order — the projection orders within a lane by this. */ + order: string[] + /** spanId -> agent node id (always equal to spanId). */ + agentBySpanId: Map + /** `${spanId}::${channel}` -> currently-open text node id (cleared on a lane break). */ + openTextByKey: Map + /** + * Results that arrived before their tool `call` (out-of-order), keyed by + * toolCallId. Raw `status`/`output` are kept so the outcome (incl. output-based + * cancellation) resolves identically to the in-order path when drained. + */ + bufferedResults: Map< + string, + { success: boolean; output?: unknown; status?: unknown; error?: string } + > + /** + * Maps a tool call id to another tool node it folds into. Used for the + * `edit_content` -> `workspace_file` row merge so the write streams into the + * single "writing" row rather than a second row. + */ + toolAlias: Map + /** Highest applied wire seq; events at or below are no-ops (cursor-idempotent replay). */ + lastSeq: number +} + +export function createTurnModel(): TurnModel { + return { + status: 'streaming', + nodes: new Map(), + order: [], + agentBySpanId: new Map(), + openTextByKey: new Map(), + bufferedResults: new Map(), + toolAlias: new Map(), + lastSeq: 0, + } +} + +const WORKSPACE_FILE_TOOL = 'workspace_file' +const EDIT_CONTENT_TOOL = 'edit_content' + +/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */ +export function resolveToolId(model: TurnModel, id: string): string { + return model.toolAlias.get(id) ?? id +} + +/** + * Finds the most recent `workspace_file` tool node in a span so an `edit_content` + * write folds into it (the single "writing" row). Co-location in the file + * subagent's span is the link — no coupling to preview phases. The caller + * reopens whatever this returns, including an already-settled row (an edit after + * a completed write is the same file operation continuing), which is the + * intended single-row behavior, not the old preview-gated parent reuse. + */ +function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode | undefined { + for (let i = model.order.length - 1; i >= 0; i--) { + const node = model.nodes.get(model.order[i]) + if (node?.kind === 'tool' && node.spanId === spanId && node.name === WORKSPACE_FILE_TOOL) { + return node + } + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +/** + * Reads a wire event payload as a generic record. The payload is a wide + * discriminated union; the reducer accesses fields uniformly, so this narrows + * through the `unknown`-typed {@link isRecord} guard rather than a double cast. + */ +function payloadRecord(payload: unknown): Record { + return isRecord(payload) ? payload : {} +} + +/** Parses a wire `ts` to epoch ms, or undefined when absent/unparseable. */ +function tsToMs(ts: unknown): number | undefined { + if (typeof ts !== 'string' || ts === '') return undefined + const ms = Date.parse(ts) + return Number.isFinite(ms) ? ms : undefined +} + +const TERMINAL_NODE_STATUSES = new Set([ + 'success', + 'error', + 'cancelled', + 'skipped', + 'rejected', +]) + +export function isNodeTerminal(status: NodeStatus): boolean { + return TERMINAL_NODE_STATUSES.has(status) +} + +/** Maps the wire turn-completion status to the status propagated to open nodes. */ +function turnTerminalNodeStatus(turn: Exclude): NodeStatus { + if (turn === 'cancelled') return 'cancelled' + if (turn === 'error') return 'error' + return 'success' +} + +/** + * Builds the inline `` tag rendered for a stream error. Kept + * byte-identical to the prior `buildInlineErrorTag` so the error special-tag + * parser renders it the same way. + */ +function buildMothershipErrorTag(payload: Record): string { + const message = + asString(payload.displayMessage) ?? + asString(payload.message) ?? + asString(payload.error) ?? + 'An unexpected error occurred' + const provider = asString(payload.provider) + const code = asString(payload.code) + return `${JSON.stringify({ + message, + ...(code ? { code } : {}), + ...(provider ? { provider } : {}), + })}` +} + +/** Closes a span's open text segment for `channel`, stamping its end time. */ +function closeOpenText( + model: TurnModel, + spanId: string, + channel: TextChannel, + atMs?: number +): void { + const key = `${spanId}::${channel}` + const nodeId = model.openTextByKey.get(key) + if (!nodeId) return + if (atMs !== undefined) { + const node = model.nodes.get(nodeId) + if (node?.kind === 'text' && node.endedAtMs === undefined) node.endedAtMs = atMs + } + model.openTextByKey.delete(key) +} + +/** Drops any open text segments in a lane so the next text starts a fresh node. */ +function breakLane(model: TurnModel, spanId: string, atMs?: number): void { + closeOpenText(model, spanId, 'assistant', atMs) + closeOpenText(model, spanId, 'thinking', atMs) +} + +function appendText( + model: TurnModel, + spanId: string, + channel: TextChannel, + text: string, + seq: number, + atMs?: number +): void { + if (!text) return + const key = `${spanId}::${channel}` + const openId = model.openTextByKey.get(key) + const open = openId ? model.nodes.get(openId) : undefined + if (open && open.kind === 'text') { + open.text += text + return + } + // A new segment supersedes the other channel's open text (e.g. the answer + // starts after thinking), which bounds the thinking segment's duration. + closeOpenText(model, spanId, channel === 'thinking' ? 'assistant' : 'thinking', atMs) + const node: TextNode = { + kind: 'text', + id: `text:${seq}`, + spanId, + channel, + text, + seq, + ...(atMs !== undefined ? { startedAtMs: atMs } : {}), + } + model.nodes.set(node.id, node) + model.order.push(node.id) + model.openTextByKey.set(key, node.id) +} + +/** + * Applies a result that raced ahead of its tool `call` (buffered under `fromId`) + * onto `node`, then clears the buffer. Used by the normal call path and by the + * edit_content -> workspace_file merge, where the buffer is keyed by the + * edit_content id but folds into the workspace_file row. + */ +function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void { + const buffered = model.bufferedResults.get(fromId) + if (!buffered) return + model.bufferedResults.delete(fromId) + node.status = resolveStreamToolOutcome({ + status: asString(buffered.status), + success: buffered.success, + output: buffered.output, + }) + node.result = { + success: buffered.success, + output: buffered.output, + ...(buffered.error ? { error: buffered.error } : {}), + } + node.streamingArgs = undefined +} + +function upsertToolNode( + model: TurnModel, + id: string, + spanId: string, + name: string, + seq: number, + atMs?: number +): ToolNode { + const existing = model.nodes.get(id) + if (existing && existing.kind === 'tool') { + if (name) existing.name = name + return existing + } + const node: ToolNode = { + kind: 'tool', + id, + spanId, + name, + status: 'running', + seq, + ...(atMs !== undefined ? { startedAtMs: atMs } : {}), + } + model.nodes.set(id, node) + model.order.push(id) + // A tool starting (or any structural event) closes the current text run. + breakLane(model, spanId, atMs) + drainBufferedResult(model, id, node) + return node +} + +function applyToolResult( + model: TurnModel, + id: string, + success: boolean, + status: unknown, + output: unknown, + error: string | undefined +): void { + const existing = model.nodes.get(id) + if (existing && existing.kind === 'tool') { + existing.status = resolveStreamToolOutcome({ status: asString(status), success, output }) + existing.result = { success, output, ...(error ? { error } : {}) } + // The args have fully resolved; drop the partial stream so the title and + // any re-serialization read the final args, not truncated streaming JSON. + existing.streamingArgs = undefined + return + } + // Result before call: buffer raw fields until the call materializes the node; + // the outcome (incl. output-based cancellation) is resolved on drain. + model.bufferedResults.set(id, { success, output, status, ...(error ? { error } : {}) }) +} + +/** + * Folds one wire envelope into the model. Pure accumulator: it mutates and + * returns the same `model` (the streaming hot path keeps one model per turn). + * Idempotent by wire `seq` so reconnect replay over a populated model is a + * no-op for already-applied events, and replay into a fresh model rebuilds the + * identical tree. + */ +export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnvelope): TurnModel { + const seq = typeof envelope.seq === 'number' ? envelope.seq : undefined + if (seq !== undefined) { + if (seq <= model.lastSeq) return model + model.lastSeq = seq + } + const seqNum = seq ?? model.order.length + 1 + const tsMs = tsToMs(envelope.ts) + const scope = envelope.scope + const spanId = scope?.spanId ?? MAIN_SPAN + + switch (envelope.type) { + case MothershipStreamV1EventType.text: { + const payload = envelope.payload + appendText(model, spanId, payload.channel as TextChannel, payload.text, seqNum, tsMs) + break + } + case MothershipStreamV1EventType.tool: { + const payload = payloadRecord(envelope.payload) + // Preview phases are a separate panel concern (decoupled from tool status). + if ('previewPhase' in payload) break + const rawToolCallId = asString(payload.toolCallId) + if (!rawToolCallId) break + const toolName = asString(payload.toolName) ?? '' + const phase = payload.phase + if (phase === MothershipStreamV1ToolPhase.call) { + // edit_content folds into its span's workspace_file row (the write + // continues in the single "writing" row), reopening it for the edit. + if (toolName === EDIT_CONTENT_TOOL) { + const parent = findWorkspaceFileNodeInSpan(model, spanId) + if (parent) { + model.toolAlias.set(rawToolCallId, parent.id) + parent.status = 'running' + parent.result = undefined + // A result that raced ahead of this call was buffered under the + // edit_content id; fold it into the reopened workspace_file row. + drainBufferedResult(model, rawToolCallId, parent) + break + } + } + const node = upsertToolNode( + model, + resolveToolId(model, rawToolCallId), + spanId, + toolName, + seqNum, + tsMs + ) + if (isRecord(payload.arguments)) node.args = payload.arguments + const ui = isRecord(payload.ui) ? payload.ui : undefined + const uiTitle = ui ? (asString(ui.title) ?? asString(ui.phaseLabel)) : undefined + if (uiTitle) node.uiTitle = uiTitle + if (ui?.hidden === true) node.hidden = true + } else if (phase === MothershipStreamV1ToolPhase.args_delta) { + const node = upsertToolNode( + model, + resolveToolId(model, rawToolCallId), + spanId, + toolName, + seqNum, + tsMs + ) + const delta = asString(payload.argumentsDelta) + if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta + } else if (phase === MothershipStreamV1ToolPhase.result) { + applyToolResult( + model, + resolveToolId(model, rawToolCallId), + payload.success === true, + payload.status, + payload.output, + asString(payload.error) + ) + } + break + } + case MothershipStreamV1EventType.span: { + const payload = envelope.payload + if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) break + const data = isRecord(payload.data) ? payload.data : undefined + const triggerToolCallId = + scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) + const agentId = asString(payload.agent) ?? scope?.agentId ?? '' + const resolvedSpanId = + scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seqNum}`) + const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN + + if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { + breakLane(model, parentSpanId, tsMs) + const existingId = model.agentBySpanId.get(resolvedSpanId) + if (existingId && model.nodes.has(existingId)) break + const node: AgentNode = { + kind: 'agent', + id: resolvedSpanId, + spanId: resolvedSpanId, + parentSpanId, + agentId, + status: 'running', + seq: seqNum, + ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), + ...(triggerToolCallId ? { triggerToolCallId } : {}), + } + model.nodes.set(node.id, node) + model.order.push(node.id) + model.agentBySpanId.set(resolvedSpanId, node.id) + } else if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) { + // A pending pause is not a terminal — the run resumes later. + if (data?.pending === true) break + breakLane(model, resolvedSpanId, tsMs) + const node = model.nodes.get(resolvedSpanId) + if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) { + node.status = data && asString(data.error) ? 'error' : 'success' + node.endSeq = seqNum + } + } + break + } + case MothershipStreamV1EventType.run: { + const payload = payloadRecord(envelope.payload) + const kind = payload.kind + if (kind === MothershipStreamV1RunKind.compaction_start) { + const node = upsertToolNode( + model, + `compaction:${seqNum}`, + spanId, + 'context_compaction', + seqNum, + tsMs + ) + node.uiTitle = 'Compacting context...' + } else if (kind === MothershipStreamV1RunKind.compaction_done) { + let finalized = false + for (let i = model.order.length - 1; i >= 0; i--) { + const node = model.nodes.get(model.order[i]) + if ( + node?.kind === 'tool' && + node.name === 'context_compaction' && + node.status === 'running' + ) { + node.status = 'success' + node.uiTitle = 'Compacted context' + finalized = true + break + } + } + if (!finalized) { + const node = upsertToolNode( + model, + `compaction:${seqNum}`, + spanId, + 'context_compaction', + seqNum, + tsMs + ) + node.status = 'success' + node.uiTitle = 'Compacted context' + } + } + break + } + case MothershipStreamV1EventType.error: { + // The error tag is content (rendered inline by the error special-tag); turn + // termination on error is applied by the stream loop's terminal handling, + // not here, so a non-fatal mid-stream error event never settles the turn. + const tag = buildMothershipErrorTag(payloadRecord(envelope.payload)) + const key = `${spanId}::assistant` + const openId = model.openTextByKey.get(key) + const open = openId ? model.nodes.get(openId) : undefined + if (open && open.kind === 'text') { + if (!open.text.includes(tag)) { + const prefix = open.text.length > 0 && !open.text.endsWith('\n') ? '\n' : '' + open.text += prefix + tag + } + } else { + appendText(model, spanId, 'assistant', tag, seqNum, tsMs) + } + break + } + case MothershipStreamV1EventType.complete: { + const payload = payloadRecord(envelope.payload) + // An async pause is not a turn terminal — the paused tools/subagents + // legitimately stay open until a later resume leg completes them. + const response = isRecord(payload.response) ? payload.response : undefined + if (response && 'async_pause' in response) break + const status = payload.status + if (status === MothershipStreamV1CompletionStatus.cancelled) { + applyTurnTerminal(model, 'cancelled') + } else if (status === MothershipStreamV1CompletionStatus.error) { + applyTurnTerminal(model, 'error') + } else { + applyTurnTerminal(model, 'complete') + } + break + } + default: + break + } + return model +} + +/** + * Sets the turn terminal and propagates it to every still-running node. This is + * the deterministic replacement for the old `interrupted` sweep: a clean + * `complete` settles stragglers as `success` (the turn succeeded), a stop as + * `cancelled`, an error as `error`. With explicit tool/span terminals there are + * normally no stragglers, so this is the abort/disconnect safety net, not a + * routine path. + */ +export function applyTurnTerminal(model: TurnModel, turn: Exclude): void { + model.status = turn + const nodeStatus = turnTerminalNodeStatus(turn) + for (const id of model.order) { + const node = model.nodes.get(id) + if (!node || node.kind === 'text') continue + if (node.status === 'running') { + node.status = nodeStatus + // Close a straggler subagent lane (no explicit span end) so the serializer + // emits its `subagent_end` and the group resolves — otherwise the + // delegating spinner spins forever after a model-driven terminal + // (error/disconnect), the bug the snapshot path closes via `endedAt`. + if (node.kind === 'agent' && node.endSeq === undefined) { + node.endSeq = model.lastSeq + } + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index dc5ae5d86d2..345e3e0c37a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -58,6 +58,7 @@ import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' import { + applyTurnTerminal, createStreamLoopContext, dispatchStreamEvent, finalizeResidualToolCalls, @@ -2025,7 +2026,7 @@ export function useChat( } } finally { if (state.sawStreamError && !state.sawCompleteEvent) { - finalizeResidualToolCalls(state.blocks, 'error') + applyTurnTerminal(state.model, 'error') ops.flush() } if (state.scheduledTextFlushFrame !== null) { @@ -2914,6 +2915,36 @@ export function useChat( const finalize = useCallback( (options?: { error?: boolean; targetChatId?: string }) => { const isError = !!options?.error + if (isError) { + const blocks = streamingBlocksRef.current + if (blocks.some((block) => block.toolCall?.status === 'executing')) { + finalizeResidualToolCalls(blocks, 'error') + const assistantId = + activeTurnRef.current?.assistantMessageId ?? + (streamIdRef.current ? getLiveAssistantMessageId(streamIdRef.current) : undefined) + const activeChatId = options?.targetChatId ?? chatIdRef.current + if (assistantId && activeChatId) { + const snapshot = buildAssistantSnapshotMessage({ + id: assistantId, + content: streamingContentRef.current, + contentBlocks: blocks, + ...(streamRequestIdRef.current ? { requestId: streamRequestIdRef.current } : {}), + }) + upsertChatHistory(activeChatId, (current) => ({ + ...current, + messages: current.messages.map((message) => + message.id === assistantId ? snapshot : message + ), + })) + } else if (assistantId) { + setPendingMessages((prev) => + prev.map((message) => + message.id === assistantId ? { ...message, contentBlocks: [...blocks] } : message + ) + ) + } + } + } const queue = useMothershipQueueStore.getState().queues[chatKeyRef.current] const hasQueuedFollowUp = !isError && (queue?.length ?? 0) > 0 reconcileTerminalPreviewSessions() @@ -2934,6 +2965,7 @@ export function useChat( notifyTurnEnded, reconcileTerminalPreviewSessions, setTransportIdle, + upsertChatHistory, ] ) finalizeRef.current = finalize From d96638e722923645ecba430103684254c27cd766 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 17 Jun 2026 11:50:56 -0700 Subject: [PATCH 04/11] improvement(subagents): update comment to reflect new go feature flag --- apps/sim/lib/copilot/request/lifecycle/run.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 7d03aa35130..12af8bfe89d 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -237,12 +237,13 @@ export async function runCopilotLifecycle( // Per-subagent checkpoint resume (concurrent fan-out) // --------------------------------------------------------------------------- // -// Under the subagent-checkpoints model each paused subagent is its OWN checkpoint -// chain (frame.checkpointId) joined at the orchestrator. Instead of one bundled -// /resume, Sim drives one resume chain per child CONCURRENTLY so a fast child -// never waits on a slow sibling, and the Go join wakes the orchestrator on -// whichever child finishes last. Gated by the Go `subagent-checkpoints` flag, -// surfaced here purely by frames carrying their own checkpointId. +// Under the per-subagent checkpoint model each paused subagent is its OWN +// checkpoint chain (frame.checkpointId) joined at the orchestrator. Instead of +// one bundled /resume, Sim drives one resume chain per child CONCURRENTLY so a +// fast child never waits on a slow sibling, and the Go join wakes the +// orchestrator on whichever child finishes last. Gated by the Go +// `parallel-subagents` flag, surfaced here purely by frames carrying their own +// checkpointId. // // IMPORTANT (concurrency): JS is single-threaded, so the legs interleave at await // points rather than running truly in parallel; shared accumulators From 1872b2947c092e654cbc8545b548b735366c05a9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 14:27:32 -0700 Subject: [PATCH 05/11] debug mode progress --- .gitignore | 8 ++ .../components/file-viewer/preview-panel.tsx | 94 ++++++++++++ .../components/agent-group/agent-group.tsx | 32 +++++ .../components/chat-content/chat-content.tsx | 33 +++++ .../message-content/message-content.tsx | 136 +++++++++++++++--- .../mothership-chat/mothership-chat.tsx | 98 ++++++++++++- .../resource-content/resource-content.tsx | 14 ++ .../home/hooks/stream/turn-model.ts | 119 ++++++++++++++- apps/sim/lib/core/security/csp.ts | 3 + 9 files changed, 516 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index c38b288a683..a700a66602a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ # bun specific bun-debug.log* +# cursor debug logs +.cursor/debug-*.log + # this repo uses bun.lock; package-lock.json files are accidental package-lock.json @@ -44,6 +47,11 @@ dump.rdb .env.test .env.production +# editor swap files +*.swp +*.swo +*.swn + # vercel .vercel diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index e9da592dac0..1354ef37922 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -913,6 +913,100 @@ const MarkdownPreview = memo(function MarkdownPreview({ revealedContent ) + // #region agent log + const mpUidRef = useRef(Math.random().toString(36).slice(2, 8)) + const revealedAtMountRef = useRef(revealedContent.length) + useEffect(() => { + const uid = mpUidRef.current + const scroller = spacerRef.current?.parentElement ?? null + if (!scroller || !isStreaming || content.length <= 60) return + // Frame 2 after a resume mount: does the freshly mounted DOM have running CSS + // animations? If so the visible "animate the whole file" is the markdown + // (re)entering with a fade on every remount, independent of Streamdown props. + let f = 0 + let raf = 0 + const tick = () => { + if (f >= 2) { + const el = scroller as Element & { + getAnimations?: (o?: { subtree?: boolean }) => Animation[] + } + const anims = el.getAnimations ? el.getAnimations({ subtree: true }) : [] + const running = anims.filter((a) => a.playState === 'running') + const names = running.slice(0, 6).map((a) => { + const eff = a.effect as KeyframeEffect | null + const target = eff?.target as Element | null + const nm = + (a as unknown as { animationName?: string; transitionProperty?: string }) + .animationName ?? + (a as unknown as { transitionProperty?: string }).transitionProperty ?? + a.constructor.name + return `${nm}@${target?.tagName ?? '?'}` + }) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F3', + location: 'preview-panel.tsx:MarkdownPreview:anim-probe', + message: 'CSS animations on resume mount (running>0 = re-animate is mount fade)', + data: { + uid, + contentLen: content.length, + scrollTop: Math.round(scroller.scrollTop), + scrollHeight: Math.round(scroller.scrollHeight), + clientHeight: Math.round(scroller.clientHeight), + animTotal: anims.length, + animRunning: running.length, + names, + }, + timestamp: Date.now(), + }), + }).catch(() => {}) + return + } + f++ + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, []) + // #endregion + + // #region agent log + // Render-time: catch the reveal jumping BACKWARD (re-reveal "from the + // beginning") or the content prop resetting, the only remaining way the file + // could appear to re-animate without a CSS animation/scroll. + const prevRevealedLenRef = useRef(revealedContent.length) + const prevContentLenRef = useRef(content.length) + if ( + revealedContent.length < prevRevealedLenRef.current - 40 || + content.length < prevContentLenRef.current - 40 + ) { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F6', + location: 'preview-panel.tsx:reveal-drop', + message: 'reveal/content dropped backward (re-reveal from beginning)', + data: { + uid: mpUidRef.current, + revealedFrom: prevRevealedLenRef.current, + revealedTo: revealedContent.length, + contentFrom: prevContentLenRef.current, + contentTo: content.length, + isStreaming, + }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + prevRevealedLenRef.current = revealedContent.length + prevContentLenRef.current = content.length + // #endregion + const contentRef = useRef(content) contentRef.current = content diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index a88a39160b6..d3a66e7ec49 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -58,6 +58,38 @@ export function AgentGroup({ defaultExpanded = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) + // #region agent log + useEffect(() => { + if (!isStreaming) return + const uid = Math.random().toString(36).slice(2, 8) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A12', + location: 'agent-group.tsx:mount', + message: 'AgentGroup MOUNT (parallel subagent flash)', + data: { uid, agentName }, + timestamp: Date.now(), + }), + }).catch(() => {}) + return () => { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A12', + location: 'agent-group.tsx:unmount', + message: 'AgentGroup UNMOUNT', + data: { uid, agentName }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + }, []) + // #endregion const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) // Pure projection of the run's own state: a subagent header spins while it is diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 3075698e179..d6d067572a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -299,6 +299,39 @@ function ChatContentInner({ const streamedContent = useSmoothText(displayContent, isStreaming) const isRevealing = isStreaming || streamedContent.length < displayContent.length + // #region agent log + useEffect(() => { + if (!isStreaming) return + const uid = Math.random().toString(36).slice(2, 8) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A5', + location: 'chat-content.tsx:mount', + message: 'streaming ChatContent MOUNT (reveal resets to 0 here)', + data: { uid, initialLen: displayContent.length }, + timestamp: Date.now(), + }), + }).catch(() => {}) + return () => { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A5', + location: 'chat-content.tsx:unmount', + message: 'streaming ChatContent UNMOUNT', + data: { uid }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + }, []) + // #endregion + useEffect(() => { onRevealStateChangeRef.current?.(isRevealing) }, [isRevealing]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index ac6100a5dbe..69783a21186 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -24,6 +24,8 @@ const FILE_SUBAGENT_ID = 'file' interface TextSegment { type: 'text' + /** Stable per-run React key (see the counters in parseBlocksWithSpanTree). */ + id: string content: string } @@ -145,10 +147,10 @@ function toToolData(tc: NonNullable): ToolCallData { const SPAN_ROOT = 'main' -function createAgentGroupSegment(name: string, idKey: string, ordinal: number): AgentGroupSegment { +function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { return { type: 'agent_group', - id: `agent-${idKey}-${ordinal}`, + id, agentName: name, agentLabel: resolveAgentLabel(name), items: [], @@ -177,6 +179,27 @@ function appendTextItem(group: AgentGroupSegment, content: string): void { function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() + // Stable per-run counters for React keys. The Nth top-level text run / Nth + // mothership group keeps the same key across re-parses (text runs and groups + // are append-only at the top level), so React never remounts the streaming + // ChatContent / AgentGroup when later segments shift array position. Keying by + // array index or block index is unstable (subagent_end interleaves, parallel + // spans reorder), which caused the disappear/re-animate + parallel-subagent flash. + let textRun = 0 + let mothershipRun = 0 + + // Canonical subagent identity: the dispatch tool call id. It is stable across + // the no-spanId (legacy parser) -> spanId (span-tree parser) transition and + // across DB-load vs live, so the group's React key never changes when the + // underlying span id is stamped — eliminating the remount/flash and keeping a + // refreshed transcript byte-identical to the live stream. + const spanAnchor = new Map() + for (const b of blocks) { + if (b.type === 'subagent' && b.spanId && b.parentToolCallId) { + spanAnchor.set(b.spanId, b.parentToolCallId) + } + } + const spanGroupKey = (spanId: string): string => `agent-${spanAnchor.get(spanId) ?? spanId}` const tailMothershipGroup = (): AgentGroupSegment | null => { const last = segments[segments.length - 1] @@ -192,7 +215,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const ensureMothership = (): AgentGroupSegment => { const existing = tailMothershipGroup() if (existing) return existing - const group = createAgentGroupSegment('mothership', 'mothership', segments.length) + const group = createAgentGroupSegment('mothership', `agent-mothership-${mothershipRun++}`) segments.push(group) return group } @@ -227,12 +250,13 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const ensureSpanGroup = ( name: string, spanId: string, - parentSpanId: string | undefined, - ordinal: number + parentSpanId: string | undefined ): AgentGroupSegment => { const existing = groupsBySpanId.get(spanId) if (existing) return existing - const group = createAgentGroupSegment(name, spanId, ordinal) + // Key by the dispatch tool call id (canonical, parser-stable) when known, + // falling back to the spanId for spans with no dispatch tool (legacy/orphan). + const group = createAgentGroupSegment(name, spanGroupKey(spanId)) groupsBySpanId.set(spanId, group) attachSpanGroup(group, parentSpanId) return group @@ -243,7 +267,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (last?.type === 'text') { last.content += content } else { - segments.push({ type: 'text', content }) + segments.push({ type: 'text', id: `text-${textRun++}`, content }) } } @@ -257,7 +281,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // (live streaming across resume legs). Create the span group on demand, // nested via parentSpanId, instead of dropping the content. if (!g && block.subagent) { - g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId, i) + g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) } if (!g) continue g.isDelegating = false @@ -288,7 +312,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (block.subagent && block.spanId) { let g = groupsBySpanId.get(block.spanId) // Out-of-order safety: see subagent_text branch above. - if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId, i) + if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) if (g) { g.isDelegating = false appendTextItem(g, block.content) @@ -305,7 +329,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // not render as a separate entry alongside the agent group. const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content] if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) - const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId, i) + const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -341,7 +365,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // span group on demand (nested via parentSpanId) so the tool nests // under its agent instead of leaking to the top-level mothership flow. if (!g && tc.calledBy) { - g = ensureSpanGroup(tc.calledBy, block.spanId, block.parentSpanId, i) + g = ensureSpanGroup(tc.calledBy, block.spanId, block.parentSpanId) } if (g) { g.isDelegating = false @@ -445,7 +469,11 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { if (existing) return { group: existing, created: false } const group: AgentGroupSegment = { type: 'agent_group', - id: `agent-${key}-${segments.length}`, + // Canonical key = the dispatch tool call id, identical to the span-tree + // parser, so a transcript that gains span ids (or a DB reload) keeps the + // same React key and never remounts. Orphans (no dispatch tool) keep the + // position-based legacy id. + id: parentToolCallId ? `agent-${parentToolCallId}` : `agent-${key}-${segments.length}`, agentName: name, agentLabel: resolveAgentLabel(name), items: [], @@ -535,7 +563,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { if (last?.type === 'text') { last.content += block.content } else { - segments.push({ type: 'text', content: block.content }) + segments.push({ type: 'text', id: `text-${i}`, content: block.content }) } continue } @@ -657,7 +685,7 @@ export function assistantMessageHasRenderableContent( parsed.length > 0 ? parsed : fallbackContent.trim() - ? [{ type: 'text' as const, content: fallbackContent }] + ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }] : [] return segments.length > 0 } @@ -701,7 +729,7 @@ function MessageContentInner({ parsed.length > 0 ? parsed : fallbackContent?.trim() - ? [{ type: 'text' as const, content: fallbackContent }] + ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }] : [] const lastSegment = segments[segments.length - 1] @@ -715,6 +743,82 @@ function MessageContentInner({ onPhaseChangeRef.current?.(phase) }, [phase]) + // #region agent log + useEffect(() => { + if (!isStreaming) return + const uid = Math.random().toString(36).slice(2, 8) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A10', + location: 'message-content.tsx:MessageContent:mount', + message: 'MessageContent MOUNT', + data: { uid }, + timestamp: Date.now(), + }), + }).catch(() => {}) + return () => { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A10', + location: 'message-content.tsx:MessageContent:unmount', + message: 'MessageContent UNMOUNT', + data: { uid }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + }, []) + // #endregion + + // #region agent log + const parseSigRef = useRef('') + useEffect(() => { + if (!isStreaming) return + const lines: string[] = [] + const walk = (segs: MessageSegment[], depth: number, parentId: string) => { + for (const s of segs) { + if (s.type !== 'agent_group') continue + const childGroups = s.items + .filter((it) => it.type === 'agent_group') + .map((it) => (it as { group: AgentGroupSegment }).group) + const toolCount = s.items.length - childGroups.length + lines.push(`${s.id}|d${depth}|p:${parentId}|n${toolCount}${s.isDelegating ? '|deleg' : ''}`) + walk(childGroups as unknown as MessageSegment[], depth + 1, s.id) + } + } + walk(segments, 0, 'ROOT') + const sig = lines.join(' ;; ') + if (sig !== parseSigRef.current) { + // Capture the raw subagent blocks' identity to learn whether the stable + // anchor (parentToolCallId) survives the provisional->real spanId swap. + const subBlocks = blocks + .filter((b) => b.type === 'subagent') + .map( + (b) => `span:${b.spanId ?? '-'}|ptc:${b.parentToolCallId ?? 'NONE'}|a:${b.content ?? '-'}` + ) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F4', + location: 'message-content.tsx:parse-tree-signature', + message: 'agent_group tree CHANGED (depth/parent flip = re-parent remount/flash)', + data: { prev: parseSigRef.current, next: sig, subBlocks }, + timestamp: Date.now(), + }), + }).catch(() => {}) + parseSigRef.current = sig + } + }) + // #endregion + if (segments.length === 0) { if (isStreaming) { return ( @@ -744,7 +848,7 @@ function MessageContentInner({ case 'text': return ( (isStreaming ? 'streaming' : 'settled') + // #region agent log + useEffect(() => { + if (!isStreaming) return + const uid = Math.random().toString(36).slice(2, 8) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A9', + location: 'mothership-chat.tsx:AssistantMessageRow:mount', + message: 'streaming row MOUNT', + data: { uid, id: message.id }, + timestamp: Date.now(), + }), + }).catch(() => {}) + return () => { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'A9', + location: 'mothership-chat.tsx:AssistantMessageRow:unmount', + message: 'streaming row UNMOUNT', + data: { uid }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + }, []) + // #endregion + const onAnimatingChangeRef = useRef(onAnimatingChange) onAnimatingChangeRef.current = onAnimatingChange useEffect(() => { @@ -214,7 +256,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ }) export function MothershipChat({ - messages, + messages: messagesProp, isSending, isReconnecting = false, isLoading = false, @@ -241,6 +283,27 @@ export function MothershipChat({ }: MothershipChatProps) { const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting + /** + * Defer the streamed message list so its re-render (virtualizer + rows) is + * low-priority: React yields it to urgent interactions (dragging/panning the + * side-panel canvas, scrolling, typing), keeping those at 60fps instead of + * starving the main thread on every streaming token. + */ + const messages = useDeferredValue(messagesProp) + // #region agent log + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'B1', + location: 'mothership-chat.tsx:MothershipChat', + message: 'list render', + data: { msgCount: messages.length, deferred: messages !== messagesProp, isStreamActive }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) @@ -300,6 +363,37 @@ export function MothershipChat({ setLastRowAnimating(false) }, [lastRowKey]) + // #region agent log + // Ungated: runs whenever the chat is mounted so it captures jank during an + // IDLE canvas drag (after the stream completes), not only during streaming. + useEffect(() => { + let raf = 0 + let last = typeof performance !== 'undefined' ? performance.now() : Date.now() + const tick = () => { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + const delta = now - last + last = now + if (delta > 50) { + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'B1', + location: 'mothership-chat.tsx:frame-probe', + message: 'long frame (jank)', + data: { frameMs: Math.round(delta), streamActive: isStreamActive }, + timestamp: Date.now(), + }), + }).catch(() => {}) + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [isStreamActive]) + // #endregion + const rangeExtractor = useCallback( (range: Range) => { const indexes = defaultRangeExtractor(range) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 050aa22b3d0..3f9b04fc12d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -514,6 +514,20 @@ interface EmbeddedWorkflowProps { } function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { + // #region agent log + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'B2', + location: 'resource-content.tsx:EmbeddedWorkflow', + message: 'canvas wrapper render', + data: { workflowId }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId) const workflowExists = (workflowList ?? []).some((w) => w.id === workflowId) const hasLoadError = useWorkflowRegistry( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index c832ee79f78..d0c78c63030 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -302,7 +302,7 @@ function upsertToolNode( ): ToolNode { const existing = model.nodes.get(id) if (existing && existing.kind === 'tool') { - if (name) existing.name = name + if (name && !existing.name) existing.name = name return existing } const node: ToolNode = { @@ -380,7 +380,39 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // edit_content folds into its span's workspace_file row (the write // continues in the single "writing" row), reopening it for the edit. if (toolName === EDIT_CONTENT_TOOL) { - const parent = findWorkspaceFileNodeInSpan(model, spanId) + // A re-emitted edit_content call (same tool call id — duplicate/replay) + // must keep its ORIGINAL target row. Re-running the span lookup can + // return a newer workspace_file, and folding into that would leave the + // first (already reopened) row running with no result ever closing it — + // a spinner stuck until the turn-terminal sweep. So once aliased, reuse. + const aliasedId = model.toolAlias.get(rawToolCallId) + const aliasedParent = aliasedId ? model.nodes.get(aliasedId) : undefined + const parent = + aliasedParent?.kind === 'tool' + ? aliasedParent + : findWorkspaceFileNodeInSpan(model, spanId) + // #region agent log + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F7', + location: 'turn-model.ts:tool-call:edit_content', + message: + 'edit_content call fold decision (reusedAlias prevents orphaned reopened row)', + data: { + rawToolCallId, + spanId, + foundParent: Boolean(parent), + reusedAlias: aliasedParent?.kind === 'tool', + parentId: parent?.id, + parentName: parent?.kind === 'tool' ? parent.name : undefined, + }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion if (parent) { model.toolAlias.set(rawToolCallId, parent.id) parent.status = 'running' @@ -416,14 +448,43 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const delta = asString(payload.argumentsDelta) if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta } else if (phase === MothershipStreamV1ToolPhase.result) { + const resolvedToolId = resolveToolId(model, rawToolCallId) + // #region agent log + const beforeNode = model.nodes.get(resolvedToolId) + // #endregion applyToolResult( model, - resolveToolId(model, rawToolCallId), + resolvedToolId, payload.success === true, payload.status, payload.output, asString(payload.error) ) + // #region agent log + const afterNode = model.nodes.get(resolvedToolId) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F7', + location: 'turn-model.ts:tool-result', + message: + 'tool result applied (nodeFound=false => buffered orphan, spinner stays running)', + data: { + rawToolCallId, + resolvedToolId, + aliased: resolvedToolId !== rawToolCallId, + toolName, + spanId, + nodeFound: Boolean(beforeNode && beforeNode.kind === 'tool'), + afterStatus: + afterNode && afterNode.kind === 'tool' ? afterNode.status : '(buffered/none)', + }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion } break } @@ -440,6 +501,35 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { breakLane(model, parentSpanId, tsMs) + // #region agent log + let priorByTrigger = '' + if (triggerToolCallId) { + for (const oid of model.order) { + const on = model.nodes.get(oid) + if ( + on?.kind === 'agent' && + on.triggerToolCallId === triggerToolCallId && + on.spanId !== resolvedSpanId + ) { + priorByTrigger = on.spanId + break + } + } + } + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F5', + location: 'turn-model.ts:span-start', + message: + 'span start (priorByTrigger!=empty => provisional->real spanId swap = group remount/flash)', + data: { resolvedSpanId, triggerToolCallId, agentId, priorByTrigger }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion const existingId = model.agentBySpanId.get(resolvedSpanId) if (existingId && model.nodes.has(existingId)) break const node: AgentNode = { @@ -562,6 +652,29 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve export function applyTurnTerminal(model: TurnModel, turn: Exclude): void { model.status = turn const nodeStatus = turnTerminalNodeStatus(turn) + // #region agent log + const stragglers = model.order + .map((id) => model.nodes.get(id)) + .filter( + (n): n is NonNullable => Boolean(n) && n!.kind !== 'text' && n!.status === 'running' + ) + .map( + (n) => + `${n.kind}:${n.kind === 'tool' ? n.name : ((n as { agentId?: string }).agentId ?? '?')}|${n.id}|span:${n.spanId}` + ) + fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, + body: JSON.stringify({ + sessionId: '3dc406', + hypothesisId: 'F7', + location: 'turn-model.ts:applyTurnTerminal', + message: 'turn terminal force-closing stragglers (these spun until turn end)', + data: { turn, stragglerCount: stragglers.length, stragglers }, + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion for (const id of model.order) { const node = model.nodes.get(id) if (!node || node.kind === 'text') continue diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 01ba261f8f7..0684ddb155f 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -82,6 +82,9 @@ const STATIC_CONNECT_SRC = [ 'https://challenges.cloudflare.com', ...(isReactGrabEnabled ? ['https://www.react-grab.com'] : []), ...(isDev ? ['ws://localhost:4722'] : []), + // #region agent log + ...(isDev ? ['http://127.0.0.1:1025'] : []), + // #endregion ...(isHosted ? [ 'https://www.googletagmanager.com', From f09cd98738468b6cbbc09d9fc9c3cbf13c477d4f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 15:36:33 -0700 Subject: [PATCH 06/11] remove debug logs --- .../components/file-viewer/preview-panel.tsx | 94 ---------------- .../components/agent-group/agent-group.tsx | 32 ------ .../components/chat-content/chat-content.tsx | 33 ------ .../message-content/message-content.tsx | 76 ------------- .../mothership-chat/mothership-chat.tsx | 78 ------------- .../resource-content/resource-content.tsx | 14 --- .../home/hooks/stream/turn-model.ts | 105 +----------------- apps/sim/lib/copilot/chat/display-message.ts | 43 ++++++- apps/sim/lib/core/security/csp.ts | 3 - 9 files changed, 43 insertions(+), 435 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 1354ef37922..e9da592dac0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -913,100 +913,6 @@ const MarkdownPreview = memo(function MarkdownPreview({ revealedContent ) - // #region agent log - const mpUidRef = useRef(Math.random().toString(36).slice(2, 8)) - const revealedAtMountRef = useRef(revealedContent.length) - useEffect(() => { - const uid = mpUidRef.current - const scroller = spacerRef.current?.parentElement ?? null - if (!scroller || !isStreaming || content.length <= 60) return - // Frame 2 after a resume mount: does the freshly mounted DOM have running CSS - // animations? If so the visible "animate the whole file" is the markdown - // (re)entering with a fade on every remount, independent of Streamdown props. - let f = 0 - let raf = 0 - const tick = () => { - if (f >= 2) { - const el = scroller as Element & { - getAnimations?: (o?: { subtree?: boolean }) => Animation[] - } - const anims = el.getAnimations ? el.getAnimations({ subtree: true }) : [] - const running = anims.filter((a) => a.playState === 'running') - const names = running.slice(0, 6).map((a) => { - const eff = a.effect as KeyframeEffect | null - const target = eff?.target as Element | null - const nm = - (a as unknown as { animationName?: string; transitionProperty?: string }) - .animationName ?? - (a as unknown as { transitionProperty?: string }).transitionProperty ?? - a.constructor.name - return `${nm}@${target?.tagName ?? '?'}` - }) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F3', - location: 'preview-panel.tsx:MarkdownPreview:anim-probe', - message: 'CSS animations on resume mount (running>0 = re-animate is mount fade)', - data: { - uid, - contentLen: content.length, - scrollTop: Math.round(scroller.scrollTop), - scrollHeight: Math.round(scroller.scrollHeight), - clientHeight: Math.round(scroller.clientHeight), - animTotal: anims.length, - animRunning: running.length, - names, - }, - timestamp: Date.now(), - }), - }).catch(() => {}) - return - } - f++ - raf = requestAnimationFrame(tick) - } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) - }, []) - // #endregion - - // #region agent log - // Render-time: catch the reveal jumping BACKWARD (re-reveal "from the - // beginning") or the content prop resetting, the only remaining way the file - // could appear to re-animate without a CSS animation/scroll. - const prevRevealedLenRef = useRef(revealedContent.length) - const prevContentLenRef = useRef(content.length) - if ( - revealedContent.length < prevRevealedLenRef.current - 40 || - content.length < prevContentLenRef.current - 40 - ) { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F6', - location: 'preview-panel.tsx:reveal-drop', - message: 'reveal/content dropped backward (re-reveal from beginning)', - data: { - uid: mpUidRef.current, - revealedFrom: prevRevealedLenRef.current, - revealedTo: revealedContent.length, - contentFrom: prevContentLenRef.current, - contentTo: content.length, - isStreaming, - }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - prevRevealedLenRef.current = revealedContent.length - prevContentLenRef.current = content.length - // #endregion - const contentRef = useRef(content) contentRef.current = content diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index d3a66e7ec49..a88a39160b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -58,38 +58,6 @@ export function AgentGroup({ defaultExpanded = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // #region agent log - useEffect(() => { - if (!isStreaming) return - const uid = Math.random().toString(36).slice(2, 8) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A12', - location: 'agent-group.tsx:mount', - message: 'AgentGroup MOUNT (parallel subagent flash)', - data: { uid, agentName }, - timestamp: Date.now(), - }), - }).catch(() => {}) - return () => { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A12', - location: 'agent-group.tsx:unmount', - message: 'AgentGroup UNMOUNT', - data: { uid, agentName }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - }, []) - // #endregion const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) // Pure projection of the run's own state: a subagent header spins while it is diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index d6d067572a4..3075698e179 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -299,39 +299,6 @@ function ChatContentInner({ const streamedContent = useSmoothText(displayContent, isStreaming) const isRevealing = isStreaming || streamedContent.length < displayContent.length - // #region agent log - useEffect(() => { - if (!isStreaming) return - const uid = Math.random().toString(36).slice(2, 8) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A5', - location: 'chat-content.tsx:mount', - message: 'streaming ChatContent MOUNT (reveal resets to 0 here)', - data: { uid, initialLen: displayContent.length }, - timestamp: Date.now(), - }), - }).catch(() => {}) - return () => { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A5', - location: 'chat-content.tsx:unmount', - message: 'streaming ChatContent UNMOUNT', - data: { uid }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - }, []) - // #endregion - useEffect(() => { onRevealStateChangeRef.current?.(isRevealing) }, [isRevealing]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 69783a21186..356ea7e00ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -743,82 +743,6 @@ function MessageContentInner({ onPhaseChangeRef.current?.(phase) }, [phase]) - // #region agent log - useEffect(() => { - if (!isStreaming) return - const uid = Math.random().toString(36).slice(2, 8) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A10', - location: 'message-content.tsx:MessageContent:mount', - message: 'MessageContent MOUNT', - data: { uid }, - timestamp: Date.now(), - }), - }).catch(() => {}) - return () => { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A10', - location: 'message-content.tsx:MessageContent:unmount', - message: 'MessageContent UNMOUNT', - data: { uid }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - }, []) - // #endregion - - // #region agent log - const parseSigRef = useRef('') - useEffect(() => { - if (!isStreaming) return - const lines: string[] = [] - const walk = (segs: MessageSegment[], depth: number, parentId: string) => { - for (const s of segs) { - if (s.type !== 'agent_group') continue - const childGroups = s.items - .filter((it) => it.type === 'agent_group') - .map((it) => (it as { group: AgentGroupSegment }).group) - const toolCount = s.items.length - childGroups.length - lines.push(`${s.id}|d${depth}|p:${parentId}|n${toolCount}${s.isDelegating ? '|deleg' : ''}`) - walk(childGroups as unknown as MessageSegment[], depth + 1, s.id) - } - } - walk(segments, 0, 'ROOT') - const sig = lines.join(' ;; ') - if (sig !== parseSigRef.current) { - // Capture the raw subagent blocks' identity to learn whether the stable - // anchor (parentToolCallId) survives the provisional->real spanId swap. - const subBlocks = blocks - .filter((b) => b.type === 'subagent') - .map( - (b) => `span:${b.spanId ?? '-'}|ptc:${b.parentToolCallId ?? 'NONE'}|a:${b.content ?? '-'}` - ) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F4', - location: 'message-content.tsx:parse-tree-signature', - message: 'agent_group tree CHANGED (depth/parent flip = re-parent remount/flash)', - data: { prev: parseSigRef.current, next: sig, subBlocks }, - timestamp: Date.now(), - }), - }).catch(() => {}) - parseSigRef.current = sig - } - }) - // #endregion - if (segments.length === 0) { if (isStreaming) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 34727084956..3eb686cb883 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -182,39 +182,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ const [phase, setPhase] = useState(isStreaming ? 'streaming' : 'settled') - // #region agent log - useEffect(() => { - if (!isStreaming) return - const uid = Math.random().toString(36).slice(2, 8) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A9', - location: 'mothership-chat.tsx:AssistantMessageRow:mount', - message: 'streaming row MOUNT', - data: { uid, id: message.id }, - timestamp: Date.now(), - }), - }).catch(() => {}) - return () => { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'A9', - location: 'mothership-chat.tsx:AssistantMessageRow:unmount', - message: 'streaming row UNMOUNT', - data: { uid }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - }, []) - // #endregion - const onAnimatingChangeRef = useRef(onAnimatingChange) onAnimatingChangeRef.current = onAnimatingChange useEffect(() => { @@ -290,20 +257,6 @@ export function MothershipChat({ * starving the main thread on every streaming token. */ const messages = useDeferredValue(messagesProp) - // #region agent log - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'B1', - location: 'mothership-chat.tsx:MothershipChat', - message: 'list render', - data: { msgCount: messages.length, deferred: messages !== messagesProp, isStreamActive }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) @@ -363,37 +316,6 @@ export function MothershipChat({ setLastRowAnimating(false) }, [lastRowKey]) - // #region agent log - // Ungated: runs whenever the chat is mounted so it captures jank during an - // IDLE canvas drag (after the stream completes), not only during streaming. - useEffect(() => { - let raf = 0 - let last = typeof performance !== 'undefined' ? performance.now() : Date.now() - const tick = () => { - const now = typeof performance !== 'undefined' ? performance.now() : Date.now() - const delta = now - last - last = now - if (delta > 50) { - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'B1', - location: 'mothership-chat.tsx:frame-probe', - message: 'long frame (jank)', - data: { frameMs: Math.round(delta), streamActive: isStreamActive }, - timestamp: Date.now(), - }), - }).catch(() => {}) - } - raf = requestAnimationFrame(tick) - } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) - }, [isStreamActive]) - // #endregion - const rangeExtractor = useCallback( (range: Range) => { const indexes = defaultRangeExtractor(range) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 3f9b04fc12d..050aa22b3d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -514,20 +514,6 @@ interface EmbeddedWorkflowProps { } function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { - // #region agent log - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'B2', - location: 'resource-content.tsx:EmbeddedWorkflow', - message: 'canvas wrapper render', - data: { workflowId }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId) const workflowExists = (workflowList ?? []).some((w) => w.id === workflowId) const hasLoadError = useWorkflowRegistry( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index d0c78c63030..923c7a15f94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -391,28 +391,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve aliasedParent?.kind === 'tool' ? aliasedParent : findWorkspaceFileNodeInSpan(model, spanId) - // #region agent log - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F7', - location: 'turn-model.ts:tool-call:edit_content', - message: - 'edit_content call fold decision (reusedAlias prevents orphaned reopened row)', - data: { - rawToolCallId, - spanId, - foundParent: Boolean(parent), - reusedAlias: aliasedParent?.kind === 'tool', - parentId: parent?.id, - parentName: parent?.kind === 'tool' ? parent.name : undefined, - }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion if (parent) { model.toolAlias.set(rawToolCallId, parent.id) parent.status = 'running' @@ -448,43 +426,14 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const delta = asString(payload.argumentsDelta) if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta } else if (phase === MothershipStreamV1ToolPhase.result) { - const resolvedToolId = resolveToolId(model, rawToolCallId) - // #region agent log - const beforeNode = model.nodes.get(resolvedToolId) - // #endregion applyToolResult( model, - resolvedToolId, + resolveToolId(model, rawToolCallId), payload.success === true, payload.status, payload.output, asString(payload.error) ) - // #region agent log - const afterNode = model.nodes.get(resolvedToolId) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F7', - location: 'turn-model.ts:tool-result', - message: - 'tool result applied (nodeFound=false => buffered orphan, spinner stays running)', - data: { - rawToolCallId, - resolvedToolId, - aliased: resolvedToolId !== rawToolCallId, - toolName, - spanId, - nodeFound: Boolean(beforeNode && beforeNode.kind === 'tool'), - afterStatus: - afterNode && afterNode.kind === 'tool' ? afterNode.status : '(buffered/none)', - }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion } break } @@ -501,35 +450,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { breakLane(model, parentSpanId, tsMs) - // #region agent log - let priorByTrigger = '' - if (triggerToolCallId) { - for (const oid of model.order) { - const on = model.nodes.get(oid) - if ( - on?.kind === 'agent' && - on.triggerToolCallId === triggerToolCallId && - on.spanId !== resolvedSpanId - ) { - priorByTrigger = on.spanId - break - } - } - } - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F5', - location: 'turn-model.ts:span-start', - message: - 'span start (priorByTrigger!=empty => provisional->real spanId swap = group remount/flash)', - data: { resolvedSpanId, triggerToolCallId, agentId, priorByTrigger }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion const existingId = model.agentBySpanId.get(resolvedSpanId) if (existingId && model.nodes.has(existingId)) break const node: AgentNode = { @@ -652,29 +572,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve export function applyTurnTerminal(model: TurnModel, turn: Exclude): void { model.status = turn const nodeStatus = turnTerminalNodeStatus(turn) - // #region agent log - const stragglers = model.order - .map((id) => model.nodes.get(id)) - .filter( - (n): n is NonNullable => Boolean(n) && n!.kind !== 'text' && n!.status === 'running' - ) - .map( - (n) => - `${n.kind}:${n.kind === 'tool' ? n.name : ((n as { agentId?: string }).agentId ?? '?')}|${n.id}|span:${n.spanId}` - ) - fetch('http://127.0.0.1:1025/ingest/85045d0a-92f7-4ee2-9de1-e2f99930c6bc', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '3dc406' }, - body: JSON.stringify({ - sessionId: '3dc406', - hypothesisId: 'F7', - location: 'turn-model.ts:applyTurnTerminal', - message: 'turn terminal force-closing stragglers (these spun until turn end)', - data: { turn, stragglerCount: stragglers.length, stragglers }, - timestamp: Date.now(), - }), - }).catch(() => {}) - // #endregion for (const id of model.order) { const node = model.nodes.get(id) if (!node || node.kind === 'text') continue diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 7b2dffa3783..ac7ba92e790 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -121,6 +121,46 @@ function toDisplayContexts( })) } +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() + 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() /** @@ -144,9 +184,10 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage { } if (msg.contentBlocks && msg.contentBlocks.length > 0) { - display.contentBlocks = msg.contentBlocks + const displayBlocks = msg.contentBlocks .map(toDisplayBlock) .filter((block): block is ContentBlock => !!block) + display.contentBlocks = foldFileWriteBlocks(displayBlocks) } const attachments = toDisplayAttachment(msg.fileAttachments) diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 0684ddb155f..01ba261f8f7 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -82,9 +82,6 @@ const STATIC_CONNECT_SRC = [ 'https://challenges.cloudflare.com', ...(isReactGrabEnabled ? ['https://www.react-grab.com'] : []), ...(isDev ? ['ws://localhost:4722'] : []), - // #region agent log - ...(isDev ? ['http://127.0.0.1:1025'] : []), - // #endregion ...(isHosted ? [ 'https://www.googletagmanager.com', From a43c647a04197de84cb560ba2f8e37205a4e9c33 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 17 Jun 2026 16:31:15 -0700 Subject: [PATCH 07/11] fix(validation): add escape annotation --- .../[workspaceId]/home/hooks/stream/turn-model-serialize.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 44f70c995c2..3e3b6cc9f6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -214,6 +214,7 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { type, payload, ...(scope ? { scope } : {}), + // double-cast-allowed: synthetic replay envelope rebuilt from ContentBlocks for reduceEvent only; payloads are intentionally the minimal shape the reducer reads (no executor/mode), never provider-parsed or re-emitted on the wire }) as unknown as PersistedStreamEventEnvelope const scopeFor = (block: ContentBlock): Record | undefined => From ed9d5ba5b73b3732c97bc38fae45765f61697f8d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 16:45:22 -0700 Subject: [PATCH 08/11] improvement(code): remove dead fallbacks --- .../home/hooks/stream/turn-model.ts | 39 +++++++++---------- .../[workspaceId]/home/hooks/use-chat.ts | 4 +- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 923c7a15f94..08f965b2ddd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -347,17 +347,16 @@ function applyToolResult( /** * Folds one wire envelope into the model. Pure accumulator: it mutates and * returns the same `model` (the streaming hot path keeps one model per turn). - * Idempotent by wire `seq` so reconnect replay over a populated model is a - * no-op for already-applied events, and replay into a fresh model rebuilds the - * identical tree. + * `seq` is the monotonic wire cursor — the contract guarantees it is always a + * finite number — so it is the sole ordering and idempotency key: an event at + * or below the applied high-water mark is a replay and no-ops (reconnect replay + * over a populated model is a no-op; replay into a fresh model rebuilds the + * identical tree). */ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnvelope): TurnModel { - const seq = typeof envelope.seq === 'number' ? envelope.seq : undefined - if (seq !== undefined) { - if (seq <= model.lastSeq) return model - model.lastSeq = seq - } - const seqNum = seq ?? model.order.length + 1 + const seq = envelope.seq + if (seq <= model.lastSeq) return model + model.lastSeq = seq const tsMs = tsToMs(envelope.ts) const scope = envelope.scope const spanId = scope?.spanId ?? MAIN_SPAN @@ -365,7 +364,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve switch (envelope.type) { case MothershipStreamV1EventType.text: { const payload = envelope.payload - appendText(model, spanId, payload.channel as TextChannel, payload.text, seqNum, tsMs) + appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -406,7 +405,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve resolveToolId(model, rawToolCallId), spanId, toolName, - seqNum, + seq, tsMs ) if (isRecord(payload.arguments)) node.args = payload.arguments @@ -420,7 +419,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve resolveToolId(model, rawToolCallId), spanId, toolName, - seqNum, + seq, tsMs ) const delta = asString(payload.argumentsDelta) @@ -445,7 +444,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const resolvedSpanId = - scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seqNum}`) + scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { @@ -459,7 +458,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve parentSpanId, agentId, status: 'running', - seq: seqNum, + seq: seq, ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), } @@ -473,7 +472,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const node = model.nodes.get(resolvedSpanId) if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) { node.status = data && asString(data.error) ? 'error' : 'success' - node.endSeq = seqNum + node.endSeq = seq } } break @@ -484,10 +483,10 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (kind === MothershipStreamV1RunKind.compaction_start) { const node = upsertToolNode( model, - `compaction:${seqNum}`, + `compaction:${seq}`, spanId, 'context_compaction', - seqNum, + seq, tsMs ) node.uiTitle = 'Compacting context...' @@ -509,10 +508,10 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (!finalized) { const node = upsertToolNode( model, - `compaction:${seqNum}`, + `compaction:${seq}`, spanId, 'context_compaction', - seqNum, + seq, tsMs ) node.status = 'success' @@ -535,7 +534,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve open.text += prefix + tag } } else { - appendText(model, spanId, 'assistant', tag, seqNum, tsMs) + appendText(model, spanId, 'assistant', tag, seq, tsMs) } break } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 345e3e0c37a..4a56b4cd943 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2011,9 +2011,7 @@ export function useChat( if (parsed.stream?.streamId) { streamIdRef.current = parsed.stream.streamId } - const eventCursor = - parsed.stream?.cursor ?? - (typeof parsed.seq === 'number' ? String(parsed.seq) : undefined) + const eventCursor = parsed.stream?.cursor ?? String(parsed.seq) if (isAlreadyProcessedStreamCursor(eventCursor, lastCursorRef.current)) { continue } From 9f2cc02c9a4212c1037766825163f0862b2cc5f3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 17:54:47 -0700 Subject: [PATCH 09/11] fix subagent lane fallback issue --- .../hooks/stream/turn-model-serialize.test.ts | 43 +++++++++++++++++++ .../home/hooks/stream/turn-model.ts | 38 ++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts index ee056511931..1690cd080e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts @@ -133,6 +133,49 @@ describe('modelToContentBlocks', () => { expect(snap2[0].content).toBe('one') }) + it('attributes subagent content that streams before its subagent_start (parallel-burst inversion)', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'R1', + parentSpanId: 'main', + parentToolCallId: 'tc-r1', + agentId: 'research', + } + const m = createTurnModel() + reduceEvent(m, env(1, 'text', { channel: 'assistant', text: 'Spawning research.' })) + // Under an 8-way burst the subagent's thinking + text can be reduced before + // its subagent_start lands. The content already carries the lane identity. + reduceEvent(m, env(2, 'text', { channel: 'thinking', text: 'Considering odds.' }, sub)) + reduceEvent(m, env(3, 'text', { channel: 'assistant', text: 'Team analysis.' }, sub)) + + // Snapshot mid-burst (before the start): the research content must already be + // its own lane, never leaked into the main ("Sim") lane with its thinking dropped. + const mid = modelToContentBlocks(m) + const midSub = mid.find((b) => b.type === 'subagent') + expect(midSub?.content).toBe('research') + expect(midSub?.spanId).toBe('R1') + expect(mid.find((b) => b.type === 'subagent_thinking')?.spanId).toBe('R1') + expect(mid.filter((b) => b.type === 'text' && b.spanId === 'R1')).toHaveLength(1) + // The main lane holds only the pre-spawn text — nothing leaked in. + const mainText = mid.filter((b) => b.type === 'text' && !b.spanId) + expect(mainText).toHaveLength(1) + expect(mainText[0].content).toBe('Spawning research.') + + // The real subagent_start lands afterward and no-ops: still one research lane. + reduceEvent( + m, + env( + 4, + 'span', + { kind: 'subagent', event: 'start', agent: 'research', data: { tool_call_id: 'tc-r1' } }, + sub + ) + ) + const after = modelToContentBlocks(m) + expect(after.filter((b) => b.type === 'subagent')).toHaveLength(1) + expect(after.find((b) => b.type === 'subagent')?.content).toBe('research') + }) + it('places subagent_end at its end seq (after the lane work), never reordering siblings', () => { const sub: Scope = { lane: 'subagent', diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 08f965b2ddd..feeade9413e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -344,6 +344,42 @@ function applyToolResult( model.bufferedResults.set(id, { success, output, status, ...(error ? { error } : {}) }) } +/** + * Materializes a subagent lane on first reference. Subagent-scoped content + * (text/thinking/tool) can be reduced before its `subagent_start` under heavy + * parallel bursts (many subagents streaming into one ordered channel); without + * the owning `AgentNode` the serializer can't attribute the content, so it leaks + * into the main lane and the subagent's thinking is dropped until the start + * lands. The wire scope already carries the lane identity (Go tags every + * forwarded subagent event with its agent id/span), so the lane is rebuilt + * deterministically from the content event itself — the symmetric counterpart to + * buffering a result before its call. The later `subagent_start` finds this node + * and no-ops. + */ +function ensureSubagentLane( + model: TurnModel, + spanId: string, + scope: { agentId?: string; parentSpanId?: string; parentToolCallId?: string } | undefined, + seq: number, + atMs?: number +): void { + if (spanId === MAIN_SPAN || model.agentBySpanId.has(spanId)) return + const node: AgentNode = { + kind: 'agent', + id: spanId, + spanId, + parentSpanId: scope?.parentSpanId ?? MAIN_SPAN, + agentId: scope?.agentId ?? '', + status: 'running', + seq, + ...(atMs !== undefined ? { startedAtMs: atMs } : {}), + ...(scope?.parentToolCallId ? { triggerToolCallId: scope.parentToolCallId } : {}), + } + model.nodes.set(node.id, node) + model.order.push(node.id) + model.agentBySpanId.set(spanId, node.id) +} + /** * Folds one wire envelope into the model. Pure accumulator: it mutates and * returns the same `model` (the streaming hot path keeps one model per turn). @@ -364,6 +400,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve switch (envelope.type) { case MothershipStreamV1EventType.text: { const payload = envelope.payload + ensureSubagentLane(model, spanId, scope, seq, tsMs) appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) break } @@ -374,6 +411,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const rawToolCallId = asString(payload.toolCallId) if (!rawToolCallId) break const toolName = asString(payload.toolName) ?? '' + ensureSubagentLane(model, spanId, scope, seq, tsMs) const phase = payload.phase if (phase === MothershipStreamV1ToolPhase.call) { // edit_content folds into its span's workspace_file row (the write From 91997588276eb5241a76141d2c0ca8b3a00119f9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 18:33:26 -0700 Subject: [PATCH 10/11] fix(mothership): increase default redis event limit to 100k from 5k --- apps/sim/lib/copilot/request/session/buffer.test.ts | 2 +- apps/sim/lib/copilot/request/session/buffer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/request/session/buffer.test.ts b/apps/sim/lib/copilot/request/session/buffer.test.ts index 6aa9bc0752a..1556ed7c3d5 100644 --- a/apps/sim/lib/copilot/request/session/buffer.test.ts +++ b/apps/sim/lib/copilot/request/session/buffer.test.ts @@ -166,7 +166,7 @@ describe('mothership-stream-outbox', () => { expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith( 'mothership_stream:stream-1:events', 0, - -5_001 + -100_001 ) }) diff --git a/apps/sim/lib/copilot/request/session/buffer.ts b/apps/sim/lib/copilot/request/session/buffer.ts index 810b0cd5a88..871bdeb8d25 100644 --- a/apps/sim/lib/copilot/request/session/buffer.ts +++ b/apps/sim/lib/copilot/request/session/buffer.ts @@ -13,7 +13,7 @@ const logger = createLogger('SessionBuffer') const STREAM_OUTBOX_PREFIX = 'mothership_stream:' const DEFAULT_TTL_SECONDS = 60 * 60 const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60 -const DEFAULT_EVENT_LIMIT = 5_000 +const DEFAULT_EVENT_LIMIT = 100_000 const RETRY_DELAYS_MS = [0, 50, 150] as const type RedisOperationMetadata = { From dfe879c6a61f22b886c9c00bf19365403cc15259 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 17 Jun 2026 23:30:58 -0700 Subject: [PATCH 11/11] fix(mothership): streaming invariant projection enforcement --- apps/docs/components/icons.tsx | 18 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/meta.json | 1 + .../docs/en/integrations/sportmonks.mdx | 1517 +++++++++++++++++ .../home/hooks/stream/turn-model.test.ts | 18 + .../home/hooks/stream/turn-model.ts | 30 + apps/sim/blocks/blocks/sportmonks.ts | 818 +++++++++ apps/sim/blocks/registry.ts | 3 + apps/sim/components/icons.tsx | 18 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 213 ++- apps/sim/tools/registry.ts | 106 ++ apps/sim/tools/sportmonks/types.ts | 89 + apps/sim/tools/sportmonks_core/get_cities.ts | 104 ++ apps/sim/tools/sportmonks_core/get_city.ts | 83 + .../tools/sportmonks_core/get_continent.ts | 83 + .../tools/sportmonks_core/get_continents.ts | 104 ++ .../tools/sportmonks_core/get_countries.ts | 104 ++ apps/sim/tools/sportmonks_core/get_country.ts | 83 + apps/sim/tools/sportmonks_core/get_region.ts | 83 + apps/sim/tools/sportmonks_core/get_regions.ts | 104 ++ .../tools/sportmonks_core/get_timezones.ts | 61 + apps/sim/tools/sportmonks_core/get_type.ts | 74 + apps/sim/tools/sportmonks_core/get_types.ts | 93 + apps/sim/tools/sportmonks_core/index.ts | 13 + .../tools/sportmonks_core/search_cities.ts | 103 ++ .../tools/sportmonks_core/search_countries.ts | 103 ++ apps/sim/tools/sportmonks_core/types.ts | 172 ++ .../tools/sportmonks_football/get_fixture.ts | 90 + .../get_fixtures_by_date.ts | 116 ++ .../get_fixtures_by_date_range.ts | 126 ++ .../sportmonks_football/get_head_to_head.ts | 125 ++ .../get_inplay_livescores.ts | 80 + .../tools/sportmonks_football/get_league.ts | 90 + .../tools/sportmonks_football/get_leagues.ts | 105 ++ .../sportmonks_football/get_livescores.ts | 80 + .../tools/sportmonks_football/get_player.ts | 90 + .../get_standings_by_season.ts | 90 + .../sim/tools/sportmonks_football/get_team.ts | 88 + .../sportmonks_football/get_team_squad.ts | 89 + .../get_topscorers_by_season.ts | 117 ++ apps/sim/tools/sportmonks_football/index.ts | 15 + .../sportmonks_football/search_players.ts | 116 ++ .../tools/sportmonks_football/search_teams.ts | 115 ++ apps/sim/tools/sportmonks_football/types.ts | 392 +++++ .../tools/sportmonks_motorsport/get_driver.ts | 89 + .../get_driver_standings_by_season.ts | 116 ++ .../sportmonks_motorsport/get_drivers.ts | 104 ++ .../sportmonks_motorsport/get_fixture.ts | 90 + .../get_fixtures_by_date.ts | 116 ++ .../get_laps_by_fixture.ts | 90 + .../sportmonks_motorsport/get_livescores.ts | 105 ++ .../get_pitstops_by_fixture.ts | 91 + .../tools/sportmonks_motorsport/get_team.ts | 89 + .../get_team_standings_by_season.ts | 116 ++ .../tools/sportmonks_motorsport/get_teams.ts | 104 ++ apps/sim/tools/sportmonks_motorsport/index.ts | 12 + .../sportmonks_motorsport/search_drivers.ts | 109 ++ apps/sim/tools/sportmonks_motorsport/types.ts | 317 ++++ .../tools/sportmonks_odds/get_bookmaker.ts | 74 + .../tools/sportmonks_odds/get_bookmakers.ts | 98 ++ .../get_inplay_odds_by_fixture.ts | 116 ++ apps/sim/tools/sportmonks_odds/get_market.ts | 74 + apps/sim/tools/sportmonks_odds/get_markets.ts | 98 ++ .../get_pre_match_odds_by_fixture.ts | 115 ++ apps/sim/tools/sportmonks_odds/index.ts | 8 + .../sportmonks_odds/search_bookmakers.ts | 97 ++ .../tools/sportmonks_odds/search_markets.ts | 97 ++ apps/sim/tools/sportmonks_odds/types.ts | 230 +++ 69 files changed, 8680 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/en/integrations/sportmonks.mdx create mode 100644 apps/sim/blocks/blocks/sportmonks.ts create mode 100644 apps/sim/tools/sportmonks/types.ts create mode 100644 apps/sim/tools/sportmonks_core/get_cities.ts create mode 100644 apps/sim/tools/sportmonks_core/get_city.ts create mode 100644 apps/sim/tools/sportmonks_core/get_continent.ts create mode 100644 apps/sim/tools/sportmonks_core/get_continents.ts create mode 100644 apps/sim/tools/sportmonks_core/get_countries.ts create mode 100644 apps/sim/tools/sportmonks_core/get_country.ts create mode 100644 apps/sim/tools/sportmonks_core/get_region.ts create mode 100644 apps/sim/tools/sportmonks_core/get_regions.ts create mode 100644 apps/sim/tools/sportmonks_core/get_timezones.ts create mode 100644 apps/sim/tools/sportmonks_core/get_type.ts create mode 100644 apps/sim/tools/sportmonks_core/get_types.ts create mode 100644 apps/sim/tools/sportmonks_core/index.ts create mode 100644 apps/sim/tools/sportmonks_core/search_cities.ts create mode 100644 apps/sim/tools/sportmonks_core/search_countries.ts create mode 100644 apps/sim/tools/sportmonks_core/types.ts create mode 100644 apps/sim/tools/sportmonks_football/get_fixture.ts create mode 100644 apps/sim/tools/sportmonks_football/get_fixtures_by_date.ts create mode 100644 apps/sim/tools/sportmonks_football/get_fixtures_by_date_range.ts create mode 100644 apps/sim/tools/sportmonks_football/get_head_to_head.ts create mode 100644 apps/sim/tools/sportmonks_football/get_inplay_livescores.ts create mode 100644 apps/sim/tools/sportmonks_football/get_league.ts create mode 100644 apps/sim/tools/sportmonks_football/get_leagues.ts create mode 100644 apps/sim/tools/sportmonks_football/get_livescores.ts create mode 100644 apps/sim/tools/sportmonks_football/get_player.ts create mode 100644 apps/sim/tools/sportmonks_football/get_standings_by_season.ts create mode 100644 apps/sim/tools/sportmonks_football/get_team.ts create mode 100644 apps/sim/tools/sportmonks_football/get_team_squad.ts create mode 100644 apps/sim/tools/sportmonks_football/get_topscorers_by_season.ts create mode 100644 apps/sim/tools/sportmonks_football/index.ts create mode 100644 apps/sim/tools/sportmonks_football/search_players.ts create mode 100644 apps/sim/tools/sportmonks_football/search_teams.ts create mode 100644 apps/sim/tools/sportmonks_football/types.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_driver.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_driver_standings_by_season.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_drivers.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_fixture.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_fixtures_by_date.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_laps_by_fixture.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_livescores.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_pitstops_by_fixture.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_team.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_team_standings_by_season.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/get_teams.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/index.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/search_drivers.ts create mode 100644 apps/sim/tools/sportmonks_motorsport/types.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_bookmaker.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_bookmakers.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_inplay_odds_by_fixture.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_market.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_markets.ts create mode 100644 apps/sim/tools/sportmonks_odds/get_pre_match_odds_by_fixture.ts create mode 100644 apps/sim/tools/sportmonks_odds/index.ts create mode 100644 apps/sim/tools/sportmonks_odds/search_bookmakers.ts create mode 100644 apps/sim/tools/sportmonks_odds/search_markets.ts create mode 100644 apps/sim/tools/sportmonks_odds/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index cd31b9713cf..ab3833ccb20 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps) { ) } +export function SportmonksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SquareIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b65dd78c64b..0d35198c0eb 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -190,6 +190,7 @@ import { SixtyfourIcon, SlackIcon, SmtpIcon, + SportmonksIcon, SQSIcon, SquareIcon, SshIcon, @@ -439,6 +440,7 @@ export const blockTypeToIconMap: Record = { sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, + sportmonks: SportmonksIcon, sqs: SQSIcon, square: SquareIcon, ssh: SshIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index ab898490da8..d67d8dc9060 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -191,6 +191,7 @@ "sixtyfour", "slack", "smtp", + "sportmonks", "sqs", "square", "ssh", diff --git a/apps/docs/content/docs/en/integrations/sportmonks.mdx b/apps/docs/content/docs/en/integrations/sportmonks.mdx new file mode 100644 index 00000000000..21383de4d6f --- /dev/null +++ b/apps/docs/content/docs/en/integrations/sportmonks.mdx @@ -0,0 +1,1517 @@ +--- +title: Sportmonks +description: Access Sportmonks football, motorsport, odds, and reference data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate the Sportmonks sports data APIs into the workflow from a single block. Football: fixtures, livescores, leagues, teams, squads, players, standings, and topscorers. Motorsport: sessions, drivers, teams, championship standings, laps, and pitstops. Odds: pre-match and in-play odds, bookmakers, and markets. Core: continents, countries, regions, cities, types, and time zones. + + + +## Actions + +### `sportmonks_football_get_livescores` + +Retrieve fixtures starting within 15 minutes and currently in progress from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of live fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_inplay_livescores` + +Retrieve all fixtures that are currently being played (in-play) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of in-play fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_date` + +Retrieve all football fixtures on a specific date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The date to fetch fixtures for, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;league\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501,271\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects for the requested date | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_date_range` + +Retrieve football fixtures between two dates (YYYY-MM-DD) from Sportmonks. Max range is 100 days. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format \(max 100 days after start\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501,271\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects within the requested date range | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixture` + +Retrieve a single football fixture by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events;lineups;statistics\) | +| `filters` | string | No | Filters to apply \(e.g. eventTypes:14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixture` | object | The requested fixture object | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_head_to_head` + +Retrieve the head-to-head fixtures between two teams from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `team1` | string | Yes | The id of the first team | +| `team2` | string | Yes | The id of the second team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of head-to-head fixture objects between the two teams | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_leagues` + +Retrieve all football leagues available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_league` + +Retrieve a single football league by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason;seasons\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `league` | object | The requested league object | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_search_teams` + +Search for football teams by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The team name to search for \(e.g. Celtic\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order teams by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects matching the search query | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_team` + +Retrieve a single football team by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue;coaches;players.player\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `team` | object | The requested team object | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_team_squad` + +Retrieve the current domestic squad for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;position\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `squad` | array | Array of squad entries for the team | +| ↳ `id` | number | Unique id of the squad record | +| ↳ `transfer_id` | number | Transfer id of the squad record | +| ↳ `player_id` | number | Player in the squad | +| ↳ `team_id` | number | Team of the squad | +| ↳ `position_id` | number | Position of the player in the squad | +| ↳ `detailed_position_id` | number | Detailed position of the player in the squad | +| ↳ `jersey_number` | number | Jersey number of the player | +| ↳ `start` | string | Start contract date of the player | +| ↳ `end` | string | End contract date of the player | + +### `sportmonks_football_search_players` + +Search for football players by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The player name to search for \(e.g. Tavernier\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;position;teams.team\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order players by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `players` | array | Array of player objects matching the search query | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_player` + +Retrieve a single football player by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `playerId` | string | Yes | The unique id of the player | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;position;teams.team;statistics\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `player` | object | The requested player object | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_standings_by_season` + +Retrieve the full league standings table for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details;form\) | +| `filters` | string | No | Filters to apply \(e.g. standingStages:77453568\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Group related to the standing | +| ↳ `round_id` | number | Round related to the standing | +| ↳ `standing_rule_id` | number | Standing rule related to the standing | +| ↳ `position` | number | Position of the team in the standing | +| ↳ `result` | string | Movement of the team in the standing | +| ↳ `points` | number | Points the team has gathered | + +### `sportmonks_football_get_topscorers_by_season` + +Retrieve the topscorers (goals, assists, cards) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;participant;type\) | +| `filters` | string | No | Filters to apply \(e.g. seasontopscorerTypes:208\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order topscorers by position \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topscorers` | array | Array of topscorer entries for the season | +| ↳ `id` | number | Unique id of the topscorer record | +| ↳ `season_id` | number | Season related to the topscorer | +| ↳ `league_id` | number | League related to the topscorer | +| ↳ `stage_id` | number | Stage related to the topscorer | +| ↳ `player_id` | number | Player related to the topscorer | +| ↳ `participant_id` | number | Team related to the topscorer | +| ↳ `type_id` | number | Type of the topscorer \(goals, assists, cards\) | +| ↳ `position` | number | Position of the topscorer | +| ↳ `total` | number | Number of goals, assists or cards | + +### `sportmonks_motorsport_get_livescores` + +Retrieve all live motorsport fixtures (sessions) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of live motorsport fixture \(session\) objects | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_fixtures_by_date` + +Retrieve motorsport fixtures (sessions) on a specific date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The date to fetch fixtures for, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of motorsport fixture \(session\) objects for the requested date | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_fixture` + +Retrieve a single motorsport fixture (session) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results;latestLaps;pitstops\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixture` | object | The requested motorsport fixture \(session\) object | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_drivers` + +Retrieve all motorsport drivers from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_driver` + +Retrieve a single motorsport driver by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `driver` | object | The requested driver object | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_search_drivers` + +Search for motorsport drivers by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The driver name to search for \(e.g. Verstappen\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects matching the search query | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_teams` + +Retrieve all motorsport teams (constructors) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team \(constructor\) objects | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_team` + +Retrieve a single motorsport team (constructor) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team \(constructor\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `team` | object | The requested team \(constructor\) object | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_driver_standings_by_season` + +Retrieve the drivers championship standings for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of driver standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_team_standings_by_season` + +Retrieve the constructors championship standings for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of team \(constructor\) standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_laps_by_fixture` + +Retrieve all laps for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `laps` | array | Array of lap objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_pitstops_by_fixture` + +Retrieve all pitstops for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pitstops` | array | Array of pitstop objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_odds_get_pre_match_odds_by_fixture` + +Retrieve pre-match odds for a fixture by fixture ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14 or winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects for the fixture | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_inplay_odds_by_fixture` + +Retrieve live (in-play) odds for a fixture by fixture ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14 or winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects for the fixture | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_bookmakers` + +Retrieve all bookmakers from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `filters` | string | No | Filters to apply \(e.g. IdAfter:bookmakerID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakers` | array | Array of bookmaker objects | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_get_bookmaker` + +Retrieve a single bookmaker by its ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `bookmakerId` | string | Yes | The unique id of the bookmaker | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmaker` | object | The requested bookmaker object | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_search_bookmakers` + +Search for bookmakers by name from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The bookmaker name to search for \(e.g. bet365\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakers` | array | Array of bookmaker objects matching the search query | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_get_markets` + +Retrieve all betting markets from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `filters` | string | No | Filters to apply \(e.g. IdAfter:marketID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `markets` | array | Array of market objects | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | + +### `sportmonks_odds_get_market` + +Retrieve a single betting market by its ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `marketId` | string | Yes | The unique id of the market | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `market` | object | The requested market object | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | + +### `sportmonks_odds_search_markets` + +Search for betting markets by name from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The market name to search for \(e.g. Over/Under\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `markets` | array | Array of market objects matching the search query | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | + +### `sportmonks_core_get_continents` + +Retrieve all continents from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. countries\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `continents` | array | Array of continent objects | +| ↳ `id` | number | Unique id of the continent | +| ↳ `name` | string | Name of the continent | +| ↳ `code` | string | Short code of the continent | + +### `sportmonks_core_get_continent` + +Retrieve a single continent by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `continentId` | string | Yes | The unique id of the continent | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. countries\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `continent` | object | The requested continent object | +| ↳ `id` | number | Unique id of the continent | +| ↳ `name` | string | Name of the continent | +| ↳ `code` | string | Short code of the continent | + +### `sportmonks_core_get_countries` + +Retrieve all countries from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent;regions\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `countries` | array | Array of country objects | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_get_country` + +Retrieve a single country by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent;regions\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `country` | object | The requested country object | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_search_countries` + +Search for countries by name from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The country name to search for \(e.g. Brazil\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `countries` | array | Array of country objects matching the search query | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_get_regions` + +Retrieve all regions from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;cities\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `regions` | array | Array of region objects | +| ↳ `id` | number | Unique id of the region | +| ↳ `country_id` | number | Country of the region | +| ↳ `name` | string | Name of the region | + +### `sportmonks_core_get_region` + +Retrieve a single region by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `regionId` | string | Yes | The unique id of the region | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;cities\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `region` | object | The requested region object | +| ↳ `id` | number | Unique id of the region | +| ↳ `country_id` | number | Country of the region | +| ↳ `name` | string | Name of the region | + +### `sportmonks_core_get_cities` + +Retrieve all cities from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cities` | array | Array of city objects | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region` | number | Region of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_get_city` + +Retrieve a single city by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `cityId` | string | Yes | The unique id of the city | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `city` | object | The requested city object | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region` | number | Region of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_search_cities` + +Search for cities by name from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The city name to search for \(e.g. London\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cities` | array | Array of city objects matching the search query | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region` | number | Region of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_get_types` + +Retrieve all types (reference data describing events, statistics, positions, etc.) from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `types` | array | Array of type objects | +| ↳ `id` | number | Unique id of the type | +| ↳ `parent_id` | number | Parent type of the type | +| ↳ `name` | string | Name of the type | +| ↳ `code` | string | Code of the type | +| ↳ `developer_name` | string | Developer name of the type | +| ↳ `group` | string | Group the type falls under | +| ↳ `description` | string | Description of the type | + +### `sportmonks_core_get_type` + +Retrieve a single type by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `typeId` | string | Yes | The unique id of the type | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | object | The requested type object | +| ↳ `id` | number | Unique id of the type | +| ↳ `parent_id` | number | Parent type of the type | +| ↳ `name` | string | Name of the type | +| ↳ `code` | string | Code of the type | +| ↳ `developer_name` | string | Developer name of the type | +| ↳ `group` | string | Group the type falls under | +| ↳ `description` | string | Description of the type | + +### `sportmonks_core_get_timezones` + +Retrieve all supported time zones (IANA names) from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timezones` | array | Array of supported IANA time zone names \(e.g. Europe/London\) | + + diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 667c3e0bb17..85d40741490 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -381,6 +381,24 @@ describe('reduceEvent — edit_content row merge', () => { expect(tool(m, 'wf-1').result?.success).toBe(true) expect(m.bufferedResults.has('ec-1')).toBe(false) }) + + it('finalizes a stale running section row when the next section opens', () => { + const sub: Scope = { lane: 'subagent', spanId: 'S1' } + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-file'), + // Section 1: the workspace_file row is reopened by its edit_content, but the + // edit's closing result is reordered/dropped — wf-1 is left running. + toolCall(2, 'wf-1', 'workspace_file', sub), + toolResult(3, 'wf-1', true, undefined, sub), + toolCall(4, 'ec-1', 'edit_content', sub), + // Section 2 opens before section 1's edit result lands. + toolCall(5, 'wf-2', 'workspace_file', sub), + ]) + // The previous section settles instead of spinning until the turn terminal... + expect(tool(m, 'wf-1').status).toBe('success') + // ...and the new section's row is the live write. + expect(tool(m, 'wf-2').status).toBe('running') + }) }) describe('reduceEvent — error tag + compaction coverage', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index feeade9413e..3a2fd46e400 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -150,6 +150,31 @@ function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode return undefined } +/** + * The file agent writes a file as strictly sequential `workspace_file` + + * `edit_content` section pairs, waiting for each to finish before the next. So + * when a new section's `workspace_file` opens, any earlier `workspace_file` row + * still `running` in the same span is a completed section whose closing + * `edit_content` result was reordered or dropped — finalize it as success so its + * "writing" spinner resolves when the next section starts, instead of lingering + * until the turn-terminal sweep. A no-op on the happy path (prior rows already + * settled on their own result). + */ +function finalizeStaleWorkspaceFiles(model: TurnModel, spanId: string): void { + for (const id of model.order) { + const node = model.nodes.get(id) + if ( + node?.kind === 'tool' && + node.spanId === spanId && + node.name === WORKSPACE_FILE_TOOL && + node.status === 'running' + ) { + node.status = 'success' + node.streamingArgs = undefined + } + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -438,6 +463,11 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve break } } + // A new file section opening settles any earlier still-running section row + // in this span (the file agent writes sections sequentially). + if (toolName === WORKSPACE_FILE_TOOL && !model.nodes.has(rawToolCallId)) { + finalizeStaleWorkspaceFiles(model, spanId) + } const node = upsertToolNode( model, resolveToolId(model, rawToolCallId), diff --git a/apps/sim/blocks/blocks/sportmonks.ts b/apps/sim/blocks/blocks/sportmonks.ts new file mode 100644 index 00000000000..4939bd51778 --- /dev/null +++ b/apps/sim/blocks/blocks/sportmonks.ts @@ -0,0 +1,818 @@ +import { SportmonksIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' + +const DATE_WAND_CONFIG = { + enabled: true, + prompt: `Generate a calendar date in YYYY-MM-DD format based on the user's description. +Examples: +- "today" -> today's date in YYYY-MM-DD +- "this weekend" -> the date of the upcoming Sunday in YYYY-MM-DD + +Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the date (e.g., "today", "this weekend")...', + generationType: 'timestamp' as const, +} + +const FOOTBALL_OPS = [ + 'football_get_livescores', + 'football_get_inplay_livescores', + 'football_get_fixtures_by_date', + 'football_get_fixtures_by_date_range', + 'football_get_fixture', + 'football_get_head_to_head', + 'football_get_leagues', + 'football_get_league', + 'football_search_teams', + 'football_get_team', + 'football_get_team_squad', + 'football_search_players', + 'football_get_player', + 'football_get_standings_by_season', + 'football_get_topscorers_by_season', +] + +const MOTORSPORT_OPS = [ + 'motorsport_get_livescores', + 'motorsport_get_fixtures_by_date', + 'motorsport_get_fixture', + 'motorsport_get_drivers', + 'motorsport_get_driver', + 'motorsport_search_drivers', + 'motorsport_get_teams', + 'motorsport_get_team', + 'motorsport_get_driver_standings_by_season', + 'motorsport_get_team_standings_by_season', + 'motorsport_get_laps_by_fixture', + 'motorsport_get_pitstops_by_fixture', +] + +const CORE_GEO_OPS = [ + 'core_get_continents', + 'core_get_continent', + 'core_get_countries', + 'core_get_country', + 'core_search_countries', + 'core_get_regions', + 'core_get_region', + 'core_get_cities', + 'core_get_city', + 'core_search_cities', +] + +const ODDS_FIXTURE_OPS = ['odds_get_pre_match_odds_by_fixture', 'odds_get_inplay_odds_by_fixture'] + +const INCLUDE_OPS = [...FOOTBALL_OPS, ...MOTORSPORT_OPS, ...ODDS_FIXTURE_OPS, ...CORE_GEO_OPS] + +const FILTER_OPS = [ + ...FOOTBALL_OPS, + ...MOTORSPORT_OPS, + ...ODDS_FIXTURE_OPS, + 'odds_get_bookmakers', + 'odds_get_markets', + 'core_get_continents', + 'core_get_countries', + 'core_get_regions', + 'core_get_cities', +] + +const PAGINATED_OPS = [ + 'football_get_fixtures_by_date', + 'football_get_fixtures_by_date_range', + 'football_get_head_to_head', + 'football_get_leagues', + 'football_search_teams', + 'football_search_players', + 'football_get_topscorers_by_season', + 'motorsport_get_livescores', + 'motorsport_get_fixtures_by_date', + 'motorsport_get_drivers', + 'motorsport_search_drivers', + 'motorsport_get_teams', + 'motorsport_get_driver_standings_by_season', + 'motorsport_get_team_standings_by_season', + 'odds_get_pre_match_odds_by_fixture', + 'odds_get_inplay_odds_by_fixture', + 'odds_get_bookmakers', + 'odds_search_bookmakers', + 'odds_get_markets', + 'odds_search_markets', + 'core_get_continents', + 'core_get_countries', + 'core_search_countries', + 'core_get_regions', + 'core_get_cities', + 'core_search_cities', + 'core_get_types', +] + +export const SportmonksBlock: BlockConfig = { + type: 'sportmonks', + name: 'Sportmonks', + description: 'Access Sportmonks football, motorsport, odds, and reference data', + longDescription: + 'Integrate the Sportmonks sports data APIs into the workflow from a single block. Football: fixtures, livescores, leagues, teams, squads, players, standings, and topscorers. Motorsport: sessions, drivers, teams, championship standings, laps, and pitstops. Odds: pre-match and in-play odds, bookmakers, and markets. Core: continents, countries, regions, cities, types, and time zones.', + docsLink: 'https://docs.sim.ai/integrations/sportmonks', + category: 'tools', + integrationType: IntegrationType.Analytics, + bgColor: '#171534', + icon: SportmonksIcon, + authMode: AuthMode.ApiKey, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + // Football + { label: 'Get Live Football Scores', id: 'football_get_livescores', group: 'Football' }, + { + label: 'Get Inplay Football Scores', + id: 'football_get_inplay_livescores', + group: 'Football', + }, + { + label: 'Get Football Fixtures by Date', + id: 'football_get_fixtures_by_date', + group: 'Football', + }, + { + label: 'Get Football Fixtures by Date Range', + id: 'football_get_fixtures_by_date_range', + group: 'Football', + }, + { label: 'Get Football Fixture by ID', id: 'football_get_fixture', group: 'Football' }, + { label: 'Get Football Head to Head', id: 'football_get_head_to_head', group: 'Football' }, + { label: 'Get Football Leagues', id: 'football_get_leagues', group: 'Football' }, + { label: 'Get Football League by ID', id: 'football_get_league', group: 'Football' }, + { label: 'Search Football Teams', id: 'football_search_teams', group: 'Football' }, + { label: 'Get Football Team by ID', id: 'football_get_team', group: 'Football' }, + { label: 'Get Football Team Squad', id: 'football_get_team_squad', group: 'Football' }, + { label: 'Search Football Players', id: 'football_search_players', group: 'Football' }, + { label: 'Get Football Player by ID', id: 'football_get_player', group: 'Football' }, + { + label: 'Get Football Standings by Season', + id: 'football_get_standings_by_season', + group: 'Football', + }, + { + label: 'Get Football Topscorers by Season', + id: 'football_get_topscorers_by_season', + group: 'Football', + }, + // Motorsport + { + label: 'Get Live Motorsport Scores', + id: 'motorsport_get_livescores', + group: 'Motorsport', + }, + { + label: 'Get Motorsport Fixtures by Date', + id: 'motorsport_get_fixtures_by_date', + group: 'Motorsport', + }, + { + label: 'Get Motorsport Fixture by ID', + id: 'motorsport_get_fixture', + group: 'Motorsport', + }, + { label: 'Get Motorsport Drivers', id: 'motorsport_get_drivers', group: 'Motorsport' }, + { label: 'Get Motorsport Driver by ID', id: 'motorsport_get_driver', group: 'Motorsport' }, + { + label: 'Search Motorsport Drivers', + id: 'motorsport_search_drivers', + group: 'Motorsport', + }, + { label: 'Get Motorsport Teams', id: 'motorsport_get_teams', group: 'Motorsport' }, + { label: 'Get Motorsport Team by ID', id: 'motorsport_get_team', group: 'Motorsport' }, + { + label: 'Get Motorsport Driver Standings by Season', + id: 'motorsport_get_driver_standings_by_season', + group: 'Motorsport', + }, + { + label: 'Get Motorsport Team Standings by Season', + id: 'motorsport_get_team_standings_by_season', + group: 'Motorsport', + }, + { + label: 'Get Motorsport Laps by Fixture', + id: 'motorsport_get_laps_by_fixture', + group: 'Motorsport', + }, + { + label: 'Get Motorsport Pitstops by Fixture', + id: 'motorsport_get_pitstops_by_fixture', + group: 'Motorsport', + }, + // Odds + { + label: 'Get Pre-match Odds by Fixture', + id: 'odds_get_pre_match_odds_by_fixture', + group: 'Odds', + }, + { + label: 'Get In-play Odds by Fixture', + id: 'odds_get_inplay_odds_by_fixture', + group: 'Odds', + }, + { label: 'Get Bookmakers', id: 'odds_get_bookmakers', group: 'Odds' }, + { label: 'Get Bookmaker by ID', id: 'odds_get_bookmaker', group: 'Odds' }, + { label: 'Search Bookmakers', id: 'odds_search_bookmakers', group: 'Odds' }, + { label: 'Get Betting Markets', id: 'odds_get_markets', group: 'Odds' }, + { label: 'Get Betting Market by ID', id: 'odds_get_market', group: 'Odds' }, + { label: 'Search Betting Markets', id: 'odds_search_markets', group: 'Odds' }, + // Core reference data + { label: 'Get Continents', id: 'core_get_continents', group: 'Core (Reference)' }, + { label: 'Get Continent by ID', id: 'core_get_continent', group: 'Core (Reference)' }, + { label: 'Get Countries', id: 'core_get_countries', group: 'Core (Reference)' }, + { label: 'Get Country by ID', id: 'core_get_country', group: 'Core (Reference)' }, + { label: 'Search Countries', id: 'core_search_countries', group: 'Core (Reference)' }, + { label: 'Get Regions', id: 'core_get_regions', group: 'Core (Reference)' }, + { label: 'Get Region by ID', id: 'core_get_region', group: 'Core (Reference)' }, + { label: 'Get Cities', id: 'core_get_cities', group: 'Core (Reference)' }, + { label: 'Get City by ID', id: 'core_get_city', group: 'Core (Reference)' }, + { label: 'Search Cities', id: 'core_search_cities', group: 'Core (Reference)' }, + { label: 'Get Types', id: 'core_get_types', group: 'Core (Reference)' }, + { label: 'Get Type by ID', id: 'core_get_type', group: 'Core (Reference)' }, + { label: 'Get Timezones', id: 'core_get_timezones', group: 'Core (Reference)' }, + ], + value: () => 'football_get_fixtures_by_date', + }, + // Date inputs (football + motorsport fixtures by date) + { + id: 'date', + title: 'Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { + field: 'operation', + value: ['football_get_fixtures_by_date', 'motorsport_get_fixtures_by_date'], + }, + required: { + field: 'operation', + value: ['football_get_fixtures_by_date', 'motorsport_get_fixtures_by_date'], + }, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'football_get_fixtures_by_date_range' }, + required: { field: 'operation', value: 'football_get_fixtures_by_date_range' }, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (max 100 days after start)', + condition: { field: 'operation', value: 'football_get_fixtures_by_date_range' }, + required: { field: 'operation', value: 'football_get_fixtures_by_date_range' }, + wandConfig: DATE_WAND_CONFIG, + }, + // Fixture ID (football + motorsport + odds fixture operations) + { + id: 'fixtureId', + title: 'Fixture ID', + type: 'short-input', + placeholder: 'Numeric fixture ID', + condition: { + field: 'operation', + value: [ + 'football_get_fixture', + 'motorsport_get_fixture', + 'motorsport_get_laps_by_fixture', + 'motorsport_get_pitstops_by_fixture', + 'odds_get_pre_match_odds_by_fixture', + 'odds_get_inplay_odds_by_fixture', + ], + }, + required: { + field: 'operation', + value: [ + 'football_get_fixture', + 'motorsport_get_fixture', + 'motorsport_get_laps_by_fixture', + 'motorsport_get_pitstops_by_fixture', + 'odds_get_pre_match_odds_by_fixture', + 'odds_get_inplay_odds_by_fixture', + ], + }, + }, + // Head to head team IDs (football) + { + id: 'team1', + title: 'Team 1 ID', + type: 'short-input', + placeholder: 'First team ID', + condition: { field: 'operation', value: 'football_get_head_to_head' }, + required: { field: 'operation', value: 'football_get_head_to_head' }, + }, + { + id: 'team2', + title: 'Team 2 ID', + type: 'short-input', + placeholder: 'Second team ID', + condition: { field: 'operation', value: 'football_get_head_to_head' }, + required: { field: 'operation', value: 'football_get_head_to_head' }, + }, + // League ID (football) + { + id: 'leagueId', + title: 'League ID', + type: 'short-input', + placeholder: 'Numeric league ID', + condition: { field: 'operation', value: 'football_get_league' }, + required: { field: 'operation', value: 'football_get_league' }, + }, + // Team ID (football + motorsport) + { + id: 'teamId', + title: 'Team ID', + type: 'short-input', + placeholder: 'Numeric team ID', + condition: { + field: 'operation', + value: ['football_get_team', 'football_get_team_squad', 'motorsport_get_team'], + }, + required: { + field: 'operation', + value: ['football_get_team', 'football_get_team_squad', 'motorsport_get_team'], + }, + }, + // Driver ID (motorsport) + { + id: 'driverId', + title: 'Driver ID', + type: 'short-input', + placeholder: 'Numeric driver ID', + condition: { field: 'operation', value: 'motorsport_get_driver' }, + required: { field: 'operation', value: 'motorsport_get_driver' }, + }, + // Player ID (football) + { + id: 'playerId', + title: 'Player ID', + type: 'short-input', + placeholder: 'Numeric player ID', + condition: { field: 'operation', value: 'football_get_player' }, + required: { field: 'operation', value: 'football_get_player' }, + }, + // Season ID (football + motorsport standings/topscorers) + { + id: 'seasonId', + title: 'Season ID', + type: 'short-input', + placeholder: 'Numeric season ID', + condition: { + field: 'operation', + value: [ + 'football_get_standings_by_season', + 'football_get_topscorers_by_season', + 'motorsport_get_driver_standings_by_season', + 'motorsport_get_team_standings_by_season', + ], + }, + required: { + field: 'operation', + value: [ + 'football_get_standings_by_season', + 'football_get_topscorers_by_season', + 'motorsport_get_driver_standings_by_season', + 'motorsport_get_team_standings_by_season', + ], + }, + }, + // Bookmaker / Market IDs (odds) + { + id: 'bookmakerId', + title: 'Bookmaker ID', + type: 'short-input', + placeholder: 'Numeric bookmaker ID', + condition: { field: 'operation', value: 'odds_get_bookmaker' }, + required: { field: 'operation', value: 'odds_get_bookmaker' }, + }, + { + id: 'marketId', + title: 'Market ID', + type: 'short-input', + placeholder: 'Numeric market ID', + condition: { field: 'operation', value: 'odds_get_market' }, + required: { field: 'operation', value: 'odds_get_market' }, + }, + // Core reference IDs + { + id: 'continentId', + title: 'Continent ID', + type: 'short-input', + placeholder: 'Numeric continent ID', + condition: { field: 'operation', value: 'core_get_continent' }, + required: { field: 'operation', value: 'core_get_continent' }, + }, + { + id: 'countryId', + title: 'Country ID', + type: 'short-input', + placeholder: 'Numeric country ID', + condition: { field: 'operation', value: 'core_get_country' }, + required: { field: 'operation', value: 'core_get_country' }, + }, + { + id: 'regionId', + title: 'Region ID', + type: 'short-input', + placeholder: 'Numeric region ID', + condition: { field: 'operation', value: 'core_get_region' }, + required: { field: 'operation', value: 'core_get_region' }, + }, + { + id: 'cityId', + title: 'City ID', + type: 'short-input', + placeholder: 'Numeric city ID', + condition: { field: 'operation', value: 'core_get_city' }, + required: { field: 'operation', value: 'core_get_city' }, + }, + { + id: 'typeId', + title: 'Type ID', + type: 'short-input', + placeholder: 'Numeric type ID', + condition: { field: 'operation', value: 'core_get_type' }, + required: { field: 'operation', value: 'core_get_type' }, + }, + // Shared search query + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Name to search for', + condition: { + field: 'operation', + value: [ + 'football_search_teams', + 'football_search_players', + 'motorsport_search_drivers', + 'odds_search_bookmakers', + 'odds_search_markets', + 'core_search_countries', + 'core_search_cities', + ], + }, + required: { + field: 'operation', + value: [ + 'football_search_teams', + 'football_search_players', + 'motorsport_search_drivers', + 'odds_search_bookmakers', + 'odds_search_markets', + 'core_search_countries', + 'core_search_cities', + ], + }, + }, + // Shared enrichment + pagination (advanced) + { + id: 'include', + title: 'Includes', + type: 'short-input', + placeholder: 'Semicolon-separated relations (e.g. participants;scores)', + mode: 'advanced', + condition: { field: 'operation', value: INCLUDE_OPS }, + }, + { + id: 'filters', + title: 'Filters', + type: 'short-input', + placeholder: 'Filters to apply (e.g. fixtureLeagues:501)', + mode: 'advanced', + condition: { field: 'operation', value: FILTER_OPS }, + }, + { + id: 'per_page', + title: 'Per Page', + type: 'short-input', + placeholder: 'Results per page (max 50)', + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPS }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: 'Page number', + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPS }, + }, + { + id: 'order', + title: 'Order', + type: 'dropdown', + options: [ + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPS }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Sportmonks API token', + password: true, + required: true, + }, + ], + tools: { + access: [ + 'sportmonks_football_get_livescores', + 'sportmonks_football_get_inplay_livescores', + 'sportmonks_football_get_fixtures_by_date', + 'sportmonks_football_get_fixtures_by_date_range', + 'sportmonks_football_get_fixture', + 'sportmonks_football_get_head_to_head', + 'sportmonks_football_get_leagues', + 'sportmonks_football_get_league', + 'sportmonks_football_search_teams', + 'sportmonks_football_get_team', + 'sportmonks_football_get_team_squad', + 'sportmonks_football_search_players', + 'sportmonks_football_get_player', + 'sportmonks_football_get_standings_by_season', + 'sportmonks_football_get_topscorers_by_season', + 'sportmonks_motorsport_get_livescores', + 'sportmonks_motorsport_get_fixtures_by_date', + 'sportmonks_motorsport_get_fixture', + 'sportmonks_motorsport_get_drivers', + 'sportmonks_motorsport_get_driver', + 'sportmonks_motorsport_search_drivers', + 'sportmonks_motorsport_get_teams', + 'sportmonks_motorsport_get_team', + 'sportmonks_motorsport_get_driver_standings_by_season', + 'sportmonks_motorsport_get_team_standings_by_season', + 'sportmonks_motorsport_get_laps_by_fixture', + 'sportmonks_motorsport_get_pitstops_by_fixture', + 'sportmonks_odds_get_pre_match_odds_by_fixture', + 'sportmonks_odds_get_inplay_odds_by_fixture', + 'sportmonks_odds_get_bookmakers', + 'sportmonks_odds_get_bookmaker', + 'sportmonks_odds_search_bookmakers', + 'sportmonks_odds_get_markets', + 'sportmonks_odds_get_market', + 'sportmonks_odds_search_markets', + 'sportmonks_core_get_continents', + 'sportmonks_core_get_continent', + 'sportmonks_core_get_countries', + 'sportmonks_core_get_country', + 'sportmonks_core_search_countries', + 'sportmonks_core_get_regions', + 'sportmonks_core_get_region', + 'sportmonks_core_get_cities', + 'sportmonks_core_get_city', + 'sportmonks_core_search_cities', + 'sportmonks_core_get_types', + 'sportmonks_core_get_type', + 'sportmonks_core_get_timezones', + ], + config: { + tool: (params) => `sportmonks_${params.operation}`, + params: (params) => { + const cleaned: Record = {} + Object.entries(params).forEach(([key, value]) => { + if (key === 'operation') return + if (value !== undefined && value !== null && value !== '') { + cleaned[key] = value + } + }) + return cleaned + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Sportmonks API token' }, + date: { type: 'string', description: 'Date in YYYY-MM-DD format' }, + startDate: { type: 'string', description: 'Start date in YYYY-MM-DD format' }, + endDate: { type: 'string', description: 'End date in YYYY-MM-DD format' }, + fixtureId: { type: 'string', description: 'Fixture (session) ID' }, + team1: { type: 'string', description: 'First team ID for head-to-head' }, + team2: { type: 'string', description: 'Second team ID for head-to-head' }, + leagueId: { type: 'string', description: 'League ID' }, + teamId: { type: 'string', description: 'Team ID' }, + driverId: { type: 'string', description: 'Driver ID' }, + playerId: { type: 'string', description: 'Player ID' }, + seasonId: { type: 'string', description: 'Season ID' }, + bookmakerId: { type: 'string', description: 'Bookmaker ID' }, + marketId: { type: 'string', description: 'Market ID' }, + continentId: { type: 'string', description: 'Continent ID' }, + countryId: { type: 'string', description: 'Country ID' }, + regionId: { type: 'string', description: 'Region ID' }, + cityId: { type: 'string', description: 'City ID' }, + typeId: { type: 'string', description: 'Type ID' }, + query: { type: 'string', description: 'Search query' }, + include: { type: 'string', description: 'Semicolon-separated relations to include' }, + filters: { type: 'string', description: 'Filters to apply' }, + per_page: { type: 'string', description: 'Results per page (max 50)' }, + page: { type: 'string', description: 'Page number' }, + order: { type: 'string', description: 'Order direction (asc or desc)' }, + }, + outputs: { + // Football + Motorsport fixtures + fixtures: { + type: 'json', + description: + 'Array of fixtures/sessions [{id, name, starting_at, league_id, season_id, state_id}] — football and motorsport', + }, + fixture: { type: 'json', description: 'Single fixture/session object' }, + // Football + leagues: { type: 'json', description: 'Array of football leagues' }, + league: { type: 'json', description: 'Single football league object' }, + teams: { type: 'json', description: 'Array of teams (football or motorsport)' }, + team: { type: 'json', description: 'Single team object (football or motorsport)' }, + squad: { type: 'json', description: 'Array of football squad entries' }, + players: { type: 'json', description: 'Array of football players' }, + player: { type: 'json', description: 'Single football player object' }, + standings: { + type: 'json', + description: 'Array of standings (football league or motorsport championship)', + }, + topscorers: { type: 'json', description: 'Array of football topscorers' }, + // Motorsport + drivers: { type: 'json', description: 'Array of motorsport drivers' }, + driver: { type: 'json', description: 'Single motorsport driver object' }, + laps: { type: 'json', description: 'Array of motorsport laps' }, + pitstops: { type: 'json', description: 'Array of motorsport pitstops' }, + // Odds + odds: { + type: 'json', + description: + 'Array of odds [{id, fixture_id, market_id, bookmaker_id, label, value, probability}]', + }, + bookmakers: { type: 'json', description: 'Array of bookmakers [{id, name, logo}]' }, + bookmaker: { type: 'json', description: 'Single bookmaker object' }, + markets: { type: 'json', description: 'Array of betting markets [{id, name}]' }, + market: { type: 'json', description: 'Single betting market object' }, + // Core reference + continents: { type: 'json', description: 'Array of continents [{id, name, code}]' }, + continent: { type: 'json', description: 'Single continent object' }, + countries: { + type: 'json', + description: 'Array of countries [{id, name, iso2, iso3, image_path}]', + }, + country: { type: 'json', description: 'Single country object' }, + regions: { type: 'json', description: 'Array of regions [{id, country_id, name}]' }, + region: { type: 'json', description: 'Single region object' }, + cities: { type: 'json', description: 'Array of cities [{id, country_id, name}]' }, + city: { type: 'json', description: 'Single city object' }, + types: { + type: 'json', + description: 'Array of types [{id, name, code, developer_name, group}]', + }, + type: { type: 'json', description: 'Single type object' }, + timezones: { type: 'json', description: 'Array of IANA time zone name strings' }, + pagination: { + type: 'json', + description: 'Pagination metadata {count, per_page, current_page, next_page, has_more}', + }, + }, +} + +export const SportmonksBlockMeta = { + tags: ['data-analytics'], + url: 'https://www.sportmonks.com', + templates: [ + { + icon: SportmonksIcon, + title: 'Daily football fixtures digest', + prompt: + "Build a scheduled daily workflow that fetches today's football fixtures from Sportmonks for the leagues I follow, summarizes the key matchups and kickoff times, and posts the digest to Slack.", + modules: ['scheduled', 'agent', 'workflows'], + category: 'productivity', + tags: ['automation', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: SportmonksIcon, + title: 'Live football score alerter', + prompt: + 'Create a scheduled workflow that polls Sportmonks inplay football scores, detects goals and status changes since the last run, and pings Slack with the updated scoreline for tracked matches.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['monitoring', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: SportmonksIcon, + title: 'Weekly league standings report', + prompt: + 'Build a scheduled weekly workflow that pulls the Sportmonks football standings and topscorers for a season, formats a league table with recent form, and emails the report to the group.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['reporting', 'analysis'], + alsoIntegrations: ['gmail'], + }, + { + icon: SportmonksIcon, + title: 'Race weekend schedule digest', + prompt: + "Build a scheduled workflow that fetches this weekend's motorsport sessions from Sportmonks, summarizes the practice, qualifying, and race times, and posts the schedule to Slack.", + modules: ['scheduled', 'agent', 'workflows'], + category: 'productivity', + tags: ['automation', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: SportmonksIcon, + title: 'Motorsport championship tracker', + prompt: + 'Create a scheduled weekly workflow that pulls the Sportmonks motorsport driver and constructor standings for the current season, formats the championship tables, and emails them to the group.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['reporting', 'analysis'], + alsoIntegrations: ['gmail'], + }, + { + icon: SportmonksIcon, + title: 'Pre-match odds snapshot', + prompt: + 'Build a workflow that pulls Sportmonks pre-match odds for a fixture across selected bookmakers, computes the implied probability for each outcome, and writes the snapshot to a table.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'analysis'], + }, + { + icon: SportmonksIcon, + title: 'Live odds movement alerter', + prompt: + 'Create a scheduled workflow that polls Sportmonks in-play odds for a fixture, detects sharp price moves since the last run, and pings Slack with the updated lines.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['monitoring', 'finance'], + alsoIntegrations: ['slack'], + }, + { + icon: SportmonksIcon, + title: 'Head-to-head match preview', + prompt: + 'Create a workflow that takes two team names, resolves them to IDs via Sportmonks football team search, pulls their head-to-head history and current standings, and writes a match preview file.', + modules: ['agent', 'files', 'workflows'], + category: 'productivity', + tags: ['research', 'content'], + }, + ], + skills: [ + { + name: 'daily-football-fixtures', + description: "List a day's football fixtures from Sportmonks, optionally filtered by league.", + content: + '# Daily Football Fixtures\n\nGet the football matches scheduled for a given day.\n\n## Steps\n1. Use Get Football Fixtures by Date with the target date in YYYY-MM-DD format.\n2. Optionally set Includes to `participants;scores;league` to enrich each fixture.\n3. Optionally set Filters such as `fixtureLeagues:501,271` to restrict to specific leagues.\n\n## Output\nA list of fixtures with kickoff time, the participating teams, and league.', + }, + { + name: 'live-football-scores', + description: 'Fetch in-play football matches and their current scores from Sportmonks.', + content: + '# Live Football Scores\n\nSee which matches are being played now and the live score.\n\n## Steps\n1. Use Get Inplay Football Scores to fetch matches in progress.\n2. Set Includes to `participants;scores` for team names and the scoreline.\n3. Optionally filter with `fixtureLeagues:501`.\n\n## Output\nA list of live fixtures, each with the two teams, current score, and match state.', + }, + { + name: 'football-league-table', + description: 'Build a football league standings table for a season from Sportmonks.', + content: + "# Football League Table\n\nGet the current standings for a competition.\n\n## Steps\n1. Find the season ID (use Get Football Leagues, then its current season).\n2. Use Get Football Standings by Season with that season ID.\n3. Set Includes to `participant` for team names and `form` for recent results.\n\n## Output\nAn ordered league table with each team's position, points, and recent form.", + }, + { + name: 'race-weekend-sessions', + description: 'List the motorsport sessions on a given date from Sportmonks.', + content: + '# Race Weekend Sessions\n\nGet the practice, qualifying, and race sessions for a day.\n\n## Steps\n1. Use Get Motorsport Fixtures by Date with the target date in YYYY-MM-DD format.\n2. Set Includes to `venue;participants` to attach the track and entrants.\n\n## Output\nA list of sessions for the day with type (Practice/Qualifying/Race), track, and start time.', + }, + { + name: 'motorsport-championship', + description: 'Fetch driver and constructor championship standings for a motorsport season.', + content: + "# Motorsport Championship\n\nGet the title race state for a season.\n\n## Steps\n1. Use Get Motorsport Driver Standings by Season with the season ID.\n2. Use Get Motorsport Team Standings by Season with the same season ID.\n3. Set Includes to `participant` for driver/team names.\n\n## Output\nOrdered drivers and constructors tables with each participant's position and points.", + }, + { + name: 'pre-match-odds', + description: 'Fetch pre-match odds for a fixture and compute implied probabilities.', + content: + '# Pre-match Odds\n\nGet the betting odds for an upcoming fixture.\n\n## Steps\n1. Use Get Pre-match Odds by Fixture with the fixture ID.\n2. Set Includes to `market;bookmaker` and optionally Filters like `bookmakers:2,14` to narrow results.\n\n## Output\nThe odds per outcome with decimal value and implied probability, grouped by market and bookmaker.', + }, + { + name: 'odds-line-shopping', + description: 'Find the best available price per outcome across bookmakers.', + content: + '# Odds Line Shopping\n\nFind the best odds for each outcome.\n\n## Steps\n1. Use Get Pre-match Odds by Fixture for the fixture (do not filter to one bookmaker).\n2. Group the returned odds by market and outcome label.\n3. For each outcome, pick the highest value and note its bookmaker_id.\n\n## Output\nFor each outcome, the best decimal price and which bookmaker offers it.', + }, + { + name: 'resolve-country', + description: + 'Resolve a country name to its Sportmonks ID and ISO codes via Core reference data.', + content: + '# Resolve Country\n\nNormalize a country name to Sportmonks reference data.\n\n## Steps\n1. Use Search Countries with the country name.\n2. Read the matching country id, iso2, iso3, and fifa_name.\n\n## Output\nThe country id plus its ISO2, ISO3, and FIFA codes for use in other lookups.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 1d2066d2656..c28abf23264 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -266,6 +266,7 @@ import { SimilarwebBlock, SimilarwebBlockMeta } from '@/blocks/blocks/similarweb import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour' import { SlackBlock, SlackBlockMeta } from '@/blocks/blocks/slack' import { SmtpBlock } from '@/blocks/blocks/smtp' +import { SportmonksBlock, SportmonksBlockMeta } from '@/blocks/blocks/sportmonks' import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify' import { SQSBlock, SQSBlockMeta } from '@/blocks/blocks/sqs' import { SquareBlock, SquareBlockMeta } from '@/blocks/blocks/square' @@ -568,6 +569,7 @@ const BLOCK_REGISTRY: Record = { sixtyfour: SixtyfourBlock, slack: SlackBlock, smtp: SmtpBlock, + sportmonks: SportmonksBlock, spotify: SpotifyBlock, sqs: SQSBlock, square: SquareBlock, @@ -827,6 +829,7 @@ const BLOCK_META_REGISTRY: Record = { similarweb: SimilarwebBlockMeta, sixtyfour: SixtyfourBlockMeta, slack: SlackBlockMeta, + sportmonks: SportmonksBlockMeta, spotify: SpotifyBlockMeta, sqs: SQSBlockMeta, square: SquareBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cd31b9713cf..ab3833ccb20 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps) { ) } +export function SportmonksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SquareIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 04b349d7c43..abf67652ad6 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -189,6 +189,7 @@ import { SixtyfourIcon, SlackIcon, SmtpIcon, + SportmonksIcon, SQSIcon, SquareIcon, SshIcon, @@ -415,6 +416,7 @@ export const blockTypeToIconMap: Record = { sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, + sportmonks: SportmonksIcon, sqs: SQSIcon, square: SquareIcon, ssh: SshIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 1c8b0a9a867..71ee0b33b49 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-06-16", + "updatedAt": "2026-06-18", "integrations": [ { "type": "onepassword", @@ -14740,6 +14740,217 @@ "category": "tools", "integrationType": "email" }, + { + "type": "sportmonks", + "slug": "sportmonks", + "name": "Sportmonks", + "description": "Access Sportmonks football, motorsport, odds, and reference data", + "longDescription": "Integrate the Sportmonks sports data APIs into the workflow from a single block. Football: fixtures, livescores, leagues, teams, squads, players, standings, and topscorers. Motorsport: sessions, drivers, teams, championship standings, laps, and pitstops. Odds: pre-match and in-play odds, bookmakers, and markets. Core: continents, countries, regions, cities, types, and time zones.", + "bgColor": "#171534", + "iconName": "SportmonksIcon", + "docsUrl": "https://docs.sim.ai/integrations/sportmonks", + "operations": [ + { + "name": "Get Live Football Scores", + "description": "Retrieve fixtures starting within 15 minutes and currently in progress from Sportmonks" + }, + { + "name": "Get Inplay Football Scores", + "description": "Retrieve all fixtures that are currently being played (in-play) from Sportmonks" + }, + { + "name": "Get Football Fixtures by Date", + "description": "Retrieve all football fixtures on a specific date (YYYY-MM-DD) from Sportmonks" + }, + { + "name": "Get Football Fixtures by Date Range", + "description": "Retrieve football fixtures between two dates (YYYY-MM-DD) from Sportmonks. Max range is 100 days." + }, + { + "name": "Get Football Fixture by ID", + "description": "Retrieve a single football fixture by its ID from Sportmonks" + }, + { + "name": "Get Football Head to Head", + "description": "Retrieve the head-to-head fixtures between two teams from Sportmonks" + }, + { + "name": "Get Football Leagues", + "description": "Retrieve all football leagues available within your Sportmonks subscription" + }, + { + "name": "Get Football League by ID", + "description": "Retrieve a single football league by its ID from Sportmonks" + }, + { + "name": "Search Football Teams", + "description": "Search for football teams by name from Sportmonks" + }, + { + "name": "Get Football Team by ID", + "description": "Retrieve a single football team by its ID from Sportmonks" + }, + { + "name": "Get Football Team Squad", + "description": "Retrieve the current domestic squad for a team by team ID from Sportmonks" + }, + { + "name": "Search Football Players", + "description": "Search for football players by name from Sportmonks" + }, + { + "name": "Get Football Player by ID", + "description": "Retrieve a single football player by their ID from Sportmonks" + }, + { + "name": "Get Football Standings by Season", + "description": "Retrieve the full league standings table for a season by season ID from Sportmonks" + }, + { + "name": "Get Football Topscorers by Season", + "description": "Retrieve the topscorers (goals, assists, cards) for a season by season ID from Sportmonks" + }, + { + "name": "Get Live Motorsport Scores", + "description": "Retrieve all live motorsport fixtures (sessions) from Sportmonks" + }, + { + "name": "Get Motorsport Fixtures by Date", + "description": "Retrieve motorsport fixtures (sessions) on a specific date (YYYY-MM-DD) from Sportmonks" + }, + { + "name": "Get Motorsport Fixture by ID", + "description": "Retrieve a single motorsport fixture (session) by its ID from Sportmonks" + }, + { + "name": "Get Motorsport Drivers", + "description": "Retrieve all motorsport drivers from Sportmonks" + }, + { + "name": "Get Motorsport Driver by ID", + "description": "Retrieve a single motorsport driver by their ID from Sportmonks" + }, + { + "name": "Search Motorsport Drivers", + "description": "Search for motorsport drivers by name from Sportmonks" + }, + { + "name": "Get Motorsport Teams", + "description": "Retrieve all motorsport teams (constructors) from Sportmonks" + }, + { + "name": "Get Motorsport Team by ID", + "description": "Retrieve a single motorsport team (constructor) by its ID from Sportmonks" + }, + { + "name": "Get Motorsport Driver Standings by Season", + "description": "Retrieve the drivers championship standings for a season by season ID from Sportmonks" + }, + { + "name": "Get Motorsport Team Standings by Season", + "description": "Retrieve the constructors championship standings for a season by season ID from Sportmonks" + }, + { + "name": "Get Motorsport Laps by Fixture", + "description": "Retrieve all laps for a motorsport fixture (session) by fixture ID from Sportmonks" + }, + { + "name": "Get Motorsport Pitstops by Fixture", + "description": "Retrieve all pitstops for a motorsport fixture (session) by fixture ID from Sportmonks" + }, + { + "name": "Get Pre-match Odds by Fixture", + "description": "Retrieve pre-match odds for a fixture by fixture ID from the Sportmonks Odds API" + }, + { + "name": "Get In-play Odds by Fixture", + "description": "Retrieve live (in-play) odds for a fixture by fixture ID from the Sportmonks Odds API" + }, + { + "name": "Get Bookmakers", + "description": "Retrieve all bookmakers from the Sportmonks Odds API" + }, + { + "name": "Get Bookmaker by ID", + "description": "Retrieve a single bookmaker by its ID from the Sportmonks Odds API" + }, + { + "name": "Search Bookmakers", + "description": "Search for bookmakers by name from the Sportmonks Odds API" + }, + { + "name": "Get Betting Markets", + "description": "Retrieve all betting markets from the Sportmonks Odds API" + }, + { + "name": "Get Betting Market by ID", + "description": "Retrieve a single betting market by its ID from the Sportmonks Odds API" + }, + { + "name": "Search Betting Markets", + "description": "Search for betting markets by name from the Sportmonks Odds API" + }, + { + "name": "Get Continents", + "description": "Retrieve all continents from the Sportmonks Core API" + }, + { + "name": "Get Continent by ID", + "description": "Retrieve a single continent by its ID from the Sportmonks Core API" + }, + { + "name": "Get Countries", + "description": "Retrieve all countries from the Sportmonks Core API" + }, + { + "name": "Get Country by ID", + "description": "Retrieve a single country by its ID from the Sportmonks Core API" + }, + { + "name": "Search Countries", + "description": "Search for countries by name from the Sportmonks Core API" + }, + { + "name": "Get Regions", + "description": "Retrieve all regions from the Sportmonks Core API" + }, + { + "name": "Get Region by ID", + "description": "Retrieve a single region by its ID from the Sportmonks Core API" + }, + { + "name": "Get Cities", + "description": "Retrieve all cities from the Sportmonks Core API" + }, + { + "name": "Get City by ID", + "description": "Retrieve a single city by its ID from the Sportmonks Core API" + }, + { + "name": "Search Cities", + "description": "Search for cities by name from the Sportmonks Core API" + }, + { + "name": "Get Types", + "description": "Retrieve all types (reference data describing events, statistics, positions, etc.) from the Sportmonks Core API" + }, + { + "name": "Get Type by ID", + "description": "Retrieve a single type by its ID from the Sportmonks Core API" + }, + { + "name": "Get Timezones", + "description": "Retrieve all supported time zones (IANA names) from the Sportmonks Core API" + } + ], + "operationCount": 48, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "analytics", + "tags": ["data-analytics"] + }, { "type": "square", "slug": "square", diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e7c8f701984..545919dbd26 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3077,6 +3077,62 @@ import { } from '@/tools/slack' import { smsSendTool } from '@/tools/sms' import { smtpSendMailTool } from '@/tools/smtp' +import { + sportmonksCoreGetCitiesTool, + sportmonksCoreGetCityTool, + sportmonksCoreGetContinentsTool, + sportmonksCoreGetContinentTool, + sportmonksCoreGetCountriesTool, + sportmonksCoreGetCountryTool, + sportmonksCoreGetRegionsTool, + sportmonksCoreGetRegionTool, + sportmonksCoreGetTimezonesTool, + sportmonksCoreGetTypesTool, + sportmonksCoreGetTypeTool, + sportmonksCoreSearchCitiesTool, + sportmonksCoreSearchCountriesTool, +} from '@/tools/sportmonks_core' +import { + sportmonksGetFixturesByDateRangeTool, + sportmonksGetFixturesByDateTool, + sportmonksGetFixtureTool, + sportmonksGetHeadToHeadTool, + sportmonksGetInplayLivescoresTool, + sportmonksGetLeaguesTool, + sportmonksGetLeagueTool, + sportmonksGetLivescoresTool, + sportmonksGetPlayerTool, + sportmonksGetStandingsBySeasonTool, + sportmonksGetTeamSquadTool, + sportmonksGetTeamTool, + sportmonksGetTopscorersBySeasonTool, + sportmonksSearchPlayersTool, + sportmonksSearchTeamsTool, +} from '@/tools/sportmonks_football' +import { + sportmonksMotorsportGetDriverStandingsBySeasonTool, + sportmonksMotorsportGetDriversTool, + sportmonksMotorsportGetDriverTool, + sportmonksMotorsportGetFixturesByDateTool, + sportmonksMotorsportGetFixtureTool, + sportmonksMotorsportGetLapsByFixtureTool, + sportmonksMotorsportGetLivescoresTool, + sportmonksMotorsportGetPitstopsByFixtureTool, + sportmonksMotorsportGetTeamStandingsBySeasonTool, + sportmonksMotorsportGetTeamsTool, + sportmonksMotorsportGetTeamTool, + sportmonksMotorsportSearchDriversTool, +} from '@/tools/sportmonks_motorsport' +import { + sportmonksOddsGetBookmakersTool, + sportmonksOddsGetBookmakerTool, + sportmonksOddsGetInplayOddsByFixtureTool, + sportmonksOddsGetMarketsTool, + sportmonksOddsGetMarketTool, + sportmonksOddsGetPreMatchOddsByFixtureTool, + sportmonksOddsSearchBookmakersTool, + sportmonksOddsSearchMarketsTool, +} from '@/tools/sportmonks_odds' import { spotifyAddPlaylistCoverTool, spotifyAddToQueueTool, @@ -4171,6 +4227,56 @@ export const tools: Record = { sendgrid_delete_template: sendGridDeleteTemplateTool, sendgrid_create_template_version: sendGridCreateTemplateVersionTool, smtp_send_mail: smtpSendMailTool, + sportmonks_football_get_fixtures_by_date: sportmonksGetFixturesByDateTool, + sportmonks_football_get_fixtures_by_date_range: sportmonksGetFixturesByDateRangeTool, + sportmonks_football_get_fixture: sportmonksGetFixtureTool, + sportmonks_football_get_head_to_head: sportmonksGetHeadToHeadTool, + sportmonks_football_get_livescores: sportmonksGetLivescoresTool, + sportmonks_football_get_inplay_livescores: sportmonksGetInplayLivescoresTool, + sportmonks_football_get_leagues: sportmonksGetLeaguesTool, + sportmonks_football_get_league: sportmonksGetLeagueTool, + sportmonks_football_search_teams: sportmonksSearchTeamsTool, + sportmonks_football_get_team: sportmonksGetTeamTool, + sportmonks_football_get_team_squad: sportmonksGetTeamSquadTool, + sportmonks_football_search_players: sportmonksSearchPlayersTool, + sportmonks_football_get_player: sportmonksGetPlayerTool, + sportmonks_football_get_standings_by_season: sportmonksGetStandingsBySeasonTool, + sportmonks_football_get_topscorers_by_season: sportmonksGetTopscorersBySeasonTool, + sportmonks_core_get_continents: sportmonksCoreGetContinentsTool, + sportmonks_core_get_continent: sportmonksCoreGetContinentTool, + sportmonks_core_get_countries: sportmonksCoreGetCountriesTool, + sportmonks_core_get_country: sportmonksCoreGetCountryTool, + sportmonks_core_search_countries: sportmonksCoreSearchCountriesTool, + sportmonks_core_get_regions: sportmonksCoreGetRegionsTool, + sportmonks_core_get_region: sportmonksCoreGetRegionTool, + sportmonks_core_get_cities: sportmonksCoreGetCitiesTool, + sportmonks_core_get_city: sportmonksCoreGetCityTool, + sportmonks_core_search_cities: sportmonksCoreSearchCitiesTool, + sportmonks_core_get_types: sportmonksCoreGetTypesTool, + sportmonks_core_get_type: sportmonksCoreGetTypeTool, + sportmonks_core_get_timezones: sportmonksCoreGetTimezonesTool, + sportmonks_motorsport_get_livescores: sportmonksMotorsportGetLivescoresTool, + sportmonks_motorsport_get_fixtures_by_date: sportmonksMotorsportGetFixturesByDateTool, + sportmonks_motorsport_get_fixture: sportmonksMotorsportGetFixtureTool, + sportmonks_motorsport_get_drivers: sportmonksMotorsportGetDriversTool, + sportmonks_motorsport_get_driver: sportmonksMotorsportGetDriverTool, + sportmonks_motorsport_search_drivers: sportmonksMotorsportSearchDriversTool, + sportmonks_motorsport_get_teams: sportmonksMotorsportGetTeamsTool, + sportmonks_motorsport_get_team: sportmonksMotorsportGetTeamTool, + sportmonks_motorsport_get_driver_standings_by_season: + sportmonksMotorsportGetDriverStandingsBySeasonTool, + sportmonks_motorsport_get_team_standings_by_season: + sportmonksMotorsportGetTeamStandingsBySeasonTool, + sportmonks_motorsport_get_laps_by_fixture: sportmonksMotorsportGetLapsByFixtureTool, + sportmonks_motorsport_get_pitstops_by_fixture: sportmonksMotorsportGetPitstopsByFixtureTool, + sportmonks_odds_get_pre_match_odds_by_fixture: sportmonksOddsGetPreMatchOddsByFixtureTool, + sportmonks_odds_get_inplay_odds_by_fixture: sportmonksOddsGetInplayOddsByFixtureTool, + sportmonks_odds_get_bookmakers: sportmonksOddsGetBookmakersTool, + sportmonks_odds_get_bookmaker: sportmonksOddsGetBookmakerTool, + sportmonks_odds_search_bookmakers: sportmonksOddsSearchBookmakersTool, + sportmonks_odds_get_markets: sportmonksOddsGetMarketsTool, + sportmonks_odds_get_market: sportmonksOddsGetMarketTool, + sportmonks_odds_search_markets: sportmonksOddsSearchMarketsTool, sftp_upload: sftpUploadTool, sftp_download: sftpDownloadTool, sftp_list: sftpListTool, diff --git a/apps/sim/tools/sportmonks/types.ts b/apps/sim/tools/sportmonks/types.ts new file mode 100644 index 00000000000..00dc3aa181c --- /dev/null +++ b/apps/sim/tools/sportmonks/types.ts @@ -0,0 +1,89 @@ +import type { OutputProperty } from '@/tools/types' + +/** + * Shared helpers and types for all Sportmonks APIs (football, motorsport, odds, + * core). This module is intentionally vendor-generic — it carries no + * sport-specific base URL or entity shapes. Each Sportmonks integration lives in + * its own `sportmonks_{api}` directory and imports these helpers from here. + */ + +/** + * Parameters shared by every Sportmonks tool. The API token is sent via the + * `Authorization` header, while `include`/`filters` are appended to the query. + */ +export interface SportmonksBaseParams { + apiKey: string + include?: string + filters?: string +} + +/** Pagination/ordering query parameters supported by paginated list endpoints. */ +export interface SportmonksPaginationParams { + per_page?: string + page?: string + order?: string +} + +/** + * Sportmonks v3 pagination metadata returned alongside paginated list responses. + * @see https://docs.sportmonks.com/v3/tutorials-and-guides/tutorials/introduction/pagination + */ +export interface SportmonksPagination { + count?: number + per_page?: number + current_page?: number + next_page?: string | null + has_more?: boolean +} + +/** Builds the auth headers for a Sportmonks request. */ +export function buildSportmonksHeaders(apiKey: string): Record { + return { + Authorization: apiKey, + Accept: 'application/json', + } +} + +/** Appends the shared Sportmonks query parameters (include, filters, pagination). */ +export function appendSportmonksQuery( + url: string, + params: SportmonksBaseParams & SportmonksPaginationParams +): string { + const query = new URLSearchParams() + if (params.include) query.append('include', params.include) + if (params.filters) query.append('filters', params.filters) + if (params.per_page) query.append('per_page', params.per_page) + if (params.page) query.append('page', params.page) + if (params.order) query.append('order', params.order) + const queryString = query.toString() + return queryString ? `${url}?${queryString}` : url +} + +/** Normalizes a Sportmonks error response into a thrown Error. */ +export function handleSportmonksError(data: any, status: number, operation: string): never { + const errorMessage = + data?.message || data?.error?.message || data?.error || `Unknown error during ${operation}` + throw new Error(`Sportmonks ${operation} failed (${status}): ${errorMessage}`) +} + +/** Output property definitions for the pagination metadata block. */ +export const SPORTMONKS_PAGINATION_PROPERTIES = { + count: { type: 'number', description: 'Number of results on the current page', optional: true }, + per_page: { type: 'number', description: 'Number of results per page', optional: true }, + current_page: { type: 'number', description: 'Current page number', optional: true }, + next_page: { + type: 'string', + description: 'URL of the next page of results', + nullable: true, + optional: true, + }, + has_more: { type: 'boolean', description: 'Whether more pages are available', optional: true }, +} as const satisfies Record + +/** Full pagination output definition reused across paginated list tools. */ +export const SPORTMONKS_PAGINATION_OUTPUT = { + type: 'object' as const, + description: 'Pagination metadata (present on paginated endpoints)', + optional: true, + properties: SPORTMONKS_PAGINATION_PROPERTIES, +} diff --git a/apps/sim/tools/sportmonks_core/get_cities.ts b/apps/sim/tools/sportmonks_core/get_cities.ts new file mode 100644 index 00000000000..5e09115e9d2 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_cities.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CITY_PROPERTIES, + SPORTMONKS_CORE_BASE_URL, + type SportmonksCity, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetCitiesParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetCitiesResponse extends ToolResponse { + output: { + cities: SportmonksCity[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreGetCitiesTool: ToolConfig< + SportmonksGetCitiesParams, + SportmonksGetCitiesResponse +> = { + id: 'sportmonks_core_get_cities', + name: 'Get Cities', + description: 'Retrieve all cities from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. region)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_CORE_BASE_URL}/cities`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_cities') + } + return { + success: true, + output: { + cities: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + cities: { + type: 'array', + description: 'Array of city objects', + items: { type: 'object', properties: SPORTMONKS_CITY_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_city.ts b/apps/sim/tools/sportmonks_core/get_city.ts new file mode 100644 index 00000000000..93e8ff8dc27 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_city.ts @@ -0,0 +1,83 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CITY_PROPERTIES, + SPORTMONKS_CORE_BASE_URL, + type SportmonksCity, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetCityParams extends SportmonksBaseParams { + cityId: string +} + +export interface SportmonksGetCityResponse extends ToolResponse { + output: { + city: SportmonksCity | null + } +} + +export const sportmonksCoreGetCityTool: ToolConfig< + SportmonksGetCityParams, + SportmonksGetCityResponse +> = { + id: 'sportmonks_core_get_city', + name: 'Get City by ID', + description: 'Retrieve a single city by its ID from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + cityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the city', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. region)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/cities/${encodeURIComponent(params.cityId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_city') + } + return { + success: true, + output: { + city: data.data ?? null, + }, + } + }, + + outputs: { + city: { + type: 'object', + description: 'The requested city object', + properties: SPORTMONKS_CITY_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_continent.ts b/apps/sim/tools/sportmonks_core/get_continent.ts new file mode 100644 index 00000000000..4c362af5864 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_continent.ts @@ -0,0 +1,83 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CONTINENT_PROPERTIES, + SPORTMONKS_CORE_BASE_URL, + type SportmonksContinent, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetContinentParams extends SportmonksBaseParams { + continentId: string +} + +export interface SportmonksGetContinentResponse extends ToolResponse { + output: { + continent: SportmonksContinent | null + } +} + +export const sportmonksCoreGetContinentTool: ToolConfig< + SportmonksGetContinentParams, + SportmonksGetContinentResponse +> = { + id: 'sportmonks_core_get_continent', + name: 'Get Continent by ID', + description: 'Retrieve a single continent by its ID from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + continentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the continent', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. countries)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/continents/${encodeURIComponent(params.continentId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_continent') + } + return { + success: true, + output: { + continent: data.data ?? null, + }, + } + }, + + outputs: { + continent: { + type: 'object', + description: 'The requested continent object', + properties: SPORTMONKS_CONTINENT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_continents.ts b/apps/sim/tools/sportmonks_core/get_continents.ts new file mode 100644 index 00000000000..54c39b29452 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_continents.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CONTINENT_PROPERTIES, + SPORTMONKS_CORE_BASE_URL, + type SportmonksContinent, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetContinentsParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetContinentsResponse extends ToolResponse { + output: { + continents: SportmonksContinent[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreGetContinentsTool: ToolConfig< + SportmonksGetContinentsParams, + SportmonksGetContinentsResponse +> = { + id: 'sportmonks_core_get_continents', + name: 'Get Continents', + description: 'Retrieve all continents from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. countries)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_CORE_BASE_URL}/continents`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_continents') + } + return { + success: true, + output: { + continents: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + continents: { + type: 'array', + description: 'Array of continent objects', + items: { type: 'object', properties: SPORTMONKS_CONTINENT_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_countries.ts b/apps/sim/tools/sportmonks_core/get_countries.ts new file mode 100644 index 00000000000..6d14938287a --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_countries.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_COUNTRY_PROPERTIES, + type SportmonksCountry, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetCountriesParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetCountriesResponse extends ToolResponse { + output: { + countries: SportmonksCountry[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreGetCountriesTool: ToolConfig< + SportmonksGetCountriesParams, + SportmonksGetCountriesResponse +> = { + id: 'sportmonks_core_get_countries', + name: 'Get Countries', + description: 'Retrieve all countries from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. continent;regions)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_CORE_BASE_URL}/countries`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_countries') + } + return { + success: true, + output: { + countries: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + countries: { + type: 'array', + description: 'Array of country objects', + items: { type: 'object', properties: SPORTMONKS_COUNTRY_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_country.ts b/apps/sim/tools/sportmonks_core/get_country.ts new file mode 100644 index 00000000000..2171fd4ab9d --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_country.ts @@ -0,0 +1,83 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_COUNTRY_PROPERTIES, + type SportmonksCountry, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetCountryParams extends SportmonksBaseParams { + countryId: string +} + +export interface SportmonksGetCountryResponse extends ToolResponse { + output: { + country: SportmonksCountry | null + } +} + +export const sportmonksCoreGetCountryTool: ToolConfig< + SportmonksGetCountryParams, + SportmonksGetCountryResponse +> = { + id: 'sportmonks_core_get_country', + name: 'Get Country by ID', + description: 'Retrieve a single country by its ID from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + countryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the country', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. continent;regions)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/countries/${encodeURIComponent(params.countryId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_country') + } + return { + success: true, + output: { + country: data.data ?? null, + }, + } + }, + + outputs: { + country: { + type: 'object', + description: 'The requested country object', + properties: SPORTMONKS_COUNTRY_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_region.ts b/apps/sim/tools/sportmonks_core/get_region.ts new file mode 100644 index 00000000000..fc6528f9c30 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_region.ts @@ -0,0 +1,83 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_REGION_PROPERTIES, + type SportmonksRegion, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetRegionParams extends SportmonksBaseParams { + regionId: string +} + +export interface SportmonksGetRegionResponse extends ToolResponse { + output: { + region: SportmonksRegion | null + } +} + +export const sportmonksCoreGetRegionTool: ToolConfig< + SportmonksGetRegionParams, + SportmonksGetRegionResponse +> = { + id: 'sportmonks_core_get_region', + name: 'Get Region by ID', + description: 'Retrieve a single region by its ID from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + regionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the region', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;cities)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/regions/${encodeURIComponent(params.regionId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_region') + } + return { + success: true, + output: { + region: data.data ?? null, + }, + } + }, + + outputs: { + region: { + type: 'object', + description: 'The requested region object', + properties: SPORTMONKS_REGION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_regions.ts b/apps/sim/tools/sportmonks_core/get_regions.ts new file mode 100644 index 00000000000..e7c279ab545 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_regions.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_REGION_PROPERTIES, + type SportmonksRegion, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetRegionsParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetRegionsResponse extends ToolResponse { + output: { + regions: SportmonksRegion[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreGetRegionsTool: ToolConfig< + SportmonksGetRegionsParams, + SportmonksGetRegionsResponse +> = { + id: 'sportmonks_core_get_regions', + name: 'Get Regions', + description: 'Retrieve all regions from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;cities)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_CORE_BASE_URL}/regions`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_regions') + } + return { + success: true, + output: { + regions: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + regions: { + type: 'array', + description: 'Array of region objects', + items: { type: 'object', properties: SPORTMONKS_REGION_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_timezones.ts b/apps/sim/tools/sportmonks_core/get_timezones.ts new file mode 100644 index 00000000000..6782af2c230 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_timezones.ts @@ -0,0 +1,61 @@ +import { + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { SPORTMONKS_CORE_BASE_URL } from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTimezonesParams extends SportmonksBaseParams {} + +export interface SportmonksGetTimezonesResponse extends ToolResponse { + output: { + timezones: string[] + } +} + +export const sportmonksCoreGetTimezonesTool: ToolConfig< + SportmonksGetTimezonesParams, + SportmonksGetTimezonesResponse +> = { + id: 'sportmonks_core_get_timezones', + name: 'Get Timezones', + description: 'Retrieve all supported time zones (IANA names) from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + }, + + request: { + url: () => `${SPORTMONKS_CORE_BASE_URL}/timezones`, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_timezones') + } + return { + success: true, + output: { + timezones: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + timezones: { + type: 'array', + description: 'Array of supported IANA time zone names (e.g. Europe/London)', + items: { type: 'string', description: 'IANA time zone name' }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_type.ts b/apps/sim/tools/sportmonks_core/get_type.ts new file mode 100644 index 00000000000..69a69e2eb79 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_type.ts @@ -0,0 +1,74 @@ +import { + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_TYPE_PROPERTIES, + type SportmonksType, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTypeParams extends SportmonksBaseParams { + typeId: string +} + +export interface SportmonksGetTypeResponse extends ToolResponse { + output: { + type: SportmonksType | null + } +} + +export const sportmonksCoreGetTypeTool: ToolConfig< + SportmonksGetTypeParams, + SportmonksGetTypeResponse +> = { + id: 'sportmonks_core_get_type', + name: 'Get Type by ID', + description: 'Retrieve a single type by its ID from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + typeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the type', + }, + }, + + request: { + url: (params) => + `${SPORTMONKS_CORE_BASE_URL}/types/${encodeURIComponent(params.typeId.trim())}`, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_type') + } + return { + success: true, + output: { + type: data.data ?? null, + }, + } + }, + + outputs: { + type: { + type: 'object', + description: 'The requested type object', + properties: SPORTMONKS_TYPE_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_core/get_types.ts b/apps/sim/tools/sportmonks_core/get_types.ts new file mode 100644 index 00000000000..1035f1d6efe --- /dev/null +++ b/apps/sim/tools/sportmonks_core/get_types.ts @@ -0,0 +1,93 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_TYPE_PROPERTIES, + type SportmonksType, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTypesParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetTypesResponse extends ToolResponse { + output: { + types: SportmonksType[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreGetTypesTool: ToolConfig< + SportmonksGetTypesParams, + SportmonksGetTypesResponse +> = { + id: 'sportmonks_core_get_types', + name: 'Get Types', + description: + 'Retrieve all types (reference data describing events, statistics, positions, etc.) from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_CORE_BASE_URL}/types`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_types') + } + return { + success: true, + output: { + types: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + types: { + type: 'array', + description: 'Array of type objects', + items: { type: 'object', properties: SPORTMONKS_TYPE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/index.ts b/apps/sim/tools/sportmonks_core/index.ts new file mode 100644 index 00000000000..1074d060463 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/index.ts @@ -0,0 +1,13 @@ +export { sportmonksCoreGetCitiesTool } from './get_cities' +export { sportmonksCoreGetCityTool } from './get_city' +export { sportmonksCoreGetContinentTool } from './get_continent' +export { sportmonksCoreGetContinentsTool } from './get_continents' +export { sportmonksCoreGetCountriesTool } from './get_countries' +export { sportmonksCoreGetCountryTool } from './get_country' +export { sportmonksCoreGetRegionTool } from './get_region' +export { sportmonksCoreGetRegionsTool } from './get_regions' +export { sportmonksCoreGetTimezonesTool } from './get_timezones' +export { sportmonksCoreGetTypeTool } from './get_type' +export { sportmonksCoreGetTypesTool } from './get_types' +export { sportmonksCoreSearchCitiesTool } from './search_cities' +export { sportmonksCoreSearchCountriesTool } from './search_countries' diff --git a/apps/sim/tools/sportmonks_core/search_cities.ts b/apps/sim/tools/sportmonks_core/search_cities.ts new file mode 100644 index 00000000000..91d463a5c41 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/search_cities.ts @@ -0,0 +1,103 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CITY_PROPERTIES, + SPORTMONKS_CORE_BASE_URL, + type SportmonksCity, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchCitiesParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchCitiesResponse extends ToolResponse { + output: { + cities: SportmonksCity[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreSearchCitiesTool: ToolConfig< + SportmonksSearchCitiesParams, + SportmonksSearchCitiesResponse +> = { + id: 'sportmonks_core_search_cities', + name: 'Search Cities', + description: 'Search for cities by name from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The city name to search for (e.g. London)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. region)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/cities/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_cities') + } + return { + success: true, + output: { + cities: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + cities: { + type: 'array', + description: 'Array of city objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_CITY_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/search_countries.ts b/apps/sim/tools/sportmonks_core/search_countries.ts new file mode 100644 index 00000000000..cf3fc3101b4 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/search_countries.ts @@ -0,0 +1,103 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_CORE_BASE_URL, + SPORTMONKS_COUNTRY_PROPERTIES, + type SportmonksCountry, +} from '@/tools/sportmonks_core/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchCountriesParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchCountriesResponse extends ToolResponse { + output: { + countries: SportmonksCountry[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksCoreSearchCountriesTool: ToolConfig< + SportmonksSearchCountriesParams, + SportmonksSearchCountriesResponse +> = { + id: 'sportmonks_core_search_countries', + name: 'Search Countries', + description: 'Search for countries by name from the Sportmonks Core API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The country name to search for (e.g. Brazil)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. continent)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_CORE_BASE_URL}/countries/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_countries') + } + return { + success: true, + output: { + countries: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + countries: { + type: 'array', + description: 'Array of country objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_COUNTRY_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_core/types.ts b/apps/sim/tools/sportmonks_core/types.ts new file mode 100644 index 00000000000..08dd316f083 --- /dev/null +++ b/apps/sim/tools/sportmonks_core/types.ts @@ -0,0 +1,172 @@ +import type { OutputProperty } from '@/tools/types' + +/** + * Base URL for the Sportmonks Core API v3 (shared reference data). + * @see https://docs.sportmonks.com/v3/core-api/core + */ +export const SPORTMONKS_CORE_BASE_URL = 'https://api.sportmonks.com/v3/core' + +/** + * Output property definitions for a Continent object. + * @see https://docs.sportmonks.com/v3/core-api/entities/core + */ +export const SPORTMONKS_CONTINENT_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the continent' }, + name: { type: 'string', description: 'Name of the continent' }, + code: { type: 'string', description: 'Short code of the continent', optional: true }, +} as const satisfies Record + +/** + * Output property definitions for a Country object. + * @see https://docs.sportmonks.com/v3/core-api/entities/core + */ +export const SPORTMONKS_COUNTRY_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the country' }, + continent_id: { type: 'number', description: 'Continent of the country', nullable: true }, + name: { type: 'string', description: 'Name of the country' }, + official_name: { type: 'string', description: 'Official name of the country', optional: true }, + fifa_name: { + type: 'string', + description: 'Official FIFA short code name', + nullable: true, + optional: true, + }, + iso2: { type: 'string', description: 'Two letter country code', nullable: true, optional: true }, + iso3: { + type: 'string', + description: 'Three letter country code', + nullable: true, + optional: true, + }, + latitude: { + type: 'string', + description: 'Latitude position of the country', + nullable: true, + optional: true, + }, + longitude: { + type: 'string', + description: 'Longitude position of the country', + nullable: true, + optional: true, + }, + geonameid: { type: 'number', description: 'Official geonameid', nullable: true, optional: true }, + borders: { + type: 'array', + description: 'Neighbouring countries (ISO3 codes)', + nullable: true, + optional: true, + items: { type: 'string', description: 'ISO3 country code' }, + }, + image_path: { type: 'string', description: 'Image path to the country flag', optional: true }, +} as const satisfies Record + +/** + * Output property definitions for a Region object. + * @see https://docs.sportmonks.com/v3/core-api/entities/core + */ +export const SPORTMONKS_REGION_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the region' }, + country_id: { type: 'number', description: 'Country of the region' }, + name: { type: 'string', description: 'Name of the region' }, +} as const satisfies Record + +/** + * Output property definitions for a City object. + * @see https://docs.sportmonks.com/v3/core-api/entities/core + */ +export const SPORTMONKS_CITY_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the city' }, + country_id: { type: 'number', description: 'Country of the city' }, + region: { type: 'number', description: 'Region of the city', nullable: true, optional: true }, + name: { type: 'string', description: 'Name of the city' }, + latitude: { type: 'string', description: 'Latitude of the city', nullable: true, optional: true }, + longitude: { + type: 'string', + description: 'Longitude of the city', + nullable: true, + optional: true, + }, + geonameid: { + type: 'number', + description: 'Official geonameid of the city', + nullable: true, + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Type object. + * @see https://docs.sportmonks.com/v3/core-api/entities/core + */ +export const SPORTMONKS_TYPE_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the type' }, + parent_id: { type: 'number', description: 'Parent type of the type', nullable: true }, + name: { type: 'string', description: 'Name of the type' }, + code: { type: 'string', description: 'Code of the type', nullable: true, optional: true }, + developer_name: { + type: 'string', + description: 'Developer name of the type', + nullable: true, + optional: true, + }, + group: { + type: 'string', + description: 'Group the type falls under', + nullable: true, + optional: true, + }, + description: { + type: 'string', + description: 'Description of the type', + nullable: true, + optional: true, + }, +} as const satisfies Record + +export interface SportmonksContinent { + id: number + name: string + code?: string +} + +export interface SportmonksCountry { + id: number + continent_id: number | null + name: string + official_name?: string + fifa_name?: string | null + iso2?: string | null + iso3?: string | null + latitude?: string | null + longitude?: string | null + geonameid?: number | null + borders?: string[] | null + image_path?: string +} + +export interface SportmonksRegion { + id: number + country_id: number + name: string +} + +export interface SportmonksCity { + id: number + country_id: number + region?: number | null + name: string + latitude?: string | null + longitude?: string | null + geonameid?: number | null +} + +export interface SportmonksType { + id: number + parent_id: number | null + name: string + code?: string | null + developer_name?: string | null + group?: string | null + description?: string | null +} diff --git a/apps/sim/tools/sportmonks_football/get_fixture.ts b/apps/sim/tools/sportmonks_football/get_fixture.ts new file mode 100644 index 00000000000..f81afecae27 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_fixture.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetFixtureParams extends SportmonksBaseParams { + fixtureId: string +} + +export interface SportmonksGetFixtureResponse extends ToolResponse { + output: { + fixture: SportmonksFixture | null + } +} + +export const sportmonksGetFixtureTool: ToolConfig< + SportmonksGetFixtureParams, + SportmonksGetFixtureResponse +> = { + id: 'sportmonks_football_get_fixture', + name: 'Get Fixture by ID', + description: 'Retrieve a single football fixture by its ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores;events;lineups;statistics)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. eventTypes:14)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/fixtures/${encodeURIComponent(params.fixtureId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_fixture') + } + return { + success: true, + output: { + fixture: data.data ?? null, + }, + } + }, + + outputs: { + fixture: { + type: 'object', + description: 'The requested fixture object', + properties: SPORTMONKS_FIXTURE_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_fixtures_by_date.ts b/apps/sim/tools/sportmonks_football/get_fixtures_by_date.ts new file mode 100644 index 00000000000..9e855825a21 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_fixtures_by_date.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetFixturesByDateParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + date: string +} + +export interface SportmonksGetFixturesByDateResponse extends ToolResponse { + output: { + fixtures: SportmonksFixture[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksGetFixturesByDateTool: ToolConfig< + SportmonksGetFixturesByDateParams, + SportmonksGetFixturesByDateResponse +> = { + id: 'sportmonks_football_get_fixtures_by_date', + name: 'Get Fixtures by Date', + description: 'Retrieve all football fixtures on a specific date (YYYY-MM-DD) from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + date: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The date to fetch fixtures for, in YYYY-MM-DD format', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores;league)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. fixtureLeagues:501,271)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order fixtures by starting_at (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/fixtures/date/${encodeURIComponent(params.date.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_fixtures_by_date') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of fixture objects for the requested date', + items: { type: 'object', properties: SPORTMONKS_FIXTURE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_fixtures_by_date_range.ts b/apps/sim/tools/sportmonks_football/get_fixtures_by_date_range.ts new file mode 100644 index 00000000000..d38eca41ba8 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_fixtures_by_date_range.ts @@ -0,0 +1,126 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetFixturesByDateRangeParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + startDate: string + endDate: string +} + +export interface SportmonksGetFixturesByDateRangeResponse extends ToolResponse { + output: { + fixtures: SportmonksFixture[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksGetFixturesByDateRangeTool: ToolConfig< + SportmonksGetFixturesByDateRangeParams, + SportmonksGetFixturesByDateRangeResponse +> = { + id: 'sportmonks_football_get_fixtures_by_date_range', + name: 'Get Fixtures by Date Range', + description: + 'Retrieve football fixtures between two dates (YYYY-MM-DD) from Sportmonks. Max range is 100 days.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date in YYYY-MM-DD format', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date in YYYY-MM-DD format (max 100 days after start)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. fixtureLeagues:501,271)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order fixtures by starting_at (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/fixtures/between/${encodeURIComponent( + params.startDate.trim() + )}/${encodeURIComponent(params.endDate.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_fixtures_by_date_range') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of fixture objects within the requested date range', + items: { type: 'object', properties: SPORTMONKS_FIXTURE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_head_to_head.ts b/apps/sim/tools/sportmonks_football/get_head_to_head.ts new file mode 100644 index 00000000000..c67f996a1a2 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_head_to_head.ts @@ -0,0 +1,125 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetHeadToHeadParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + team1: string + team2: string +} + +export interface SportmonksGetHeadToHeadResponse extends ToolResponse { + output: { + fixtures: SportmonksFixture[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksGetHeadToHeadTool: ToolConfig< + SportmonksGetHeadToHeadParams, + SportmonksGetHeadToHeadResponse +> = { + id: 'sportmonks_football_get_head_to_head', + name: 'Get Head to Head', + description: 'Retrieve the head-to-head fixtures between two teams from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + team1: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The id of the first team', + }, + team2: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The id of the second team', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order fixtures by starting_at (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/fixtures/head-to-head/${encodeURIComponent( + params.team1.trim() + )}/${encodeURIComponent(params.team2.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_head_to_head') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of head-to-head fixture objects between the two teams', + items: { type: 'object', properties: SPORTMONKS_FIXTURE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_inplay_livescores.ts b/apps/sim/tools/sportmonks_football/get_inplay_livescores.ts new file mode 100644 index 00000000000..b594afb6eb0 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_inplay_livescores.ts @@ -0,0 +1,80 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetInplayLivescoresParams extends SportmonksBaseParams {} + +export interface SportmonksGetInplayLivescoresResponse extends ToolResponse { + output: { + fixtures: SportmonksFixture[] + } +} + +export const sportmonksGetInplayLivescoresTool: ToolConfig< + SportmonksGetInplayLivescoresParams, + SportmonksGetInplayLivescoresResponse +> = { + id: 'sportmonks_football_get_inplay_livescores', + name: 'Get Inplay Livescores', + description: 'Retrieve all fixtures that are currently being played (in-play) from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores;events)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. fixtureLeagues:501)', + }, + }, + + request: { + url: (params) => + appendSportmonksQuery(`${SPORTMONKS_FOOTBALL_BASE_URL}/livescores/inplay`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_inplay_livescores') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of in-play fixture objects', + items: { type: 'object', properties: SPORTMONKS_FIXTURE_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_league.ts b/apps/sim/tools/sportmonks_football/get_league.ts new file mode 100644 index 00000000000..969d17b90c9 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_league.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_LEAGUE_PROPERTIES, + type SportmonksLeague, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetLeagueParams extends SportmonksBaseParams { + leagueId: string +} + +export interface SportmonksGetLeagueResponse extends ToolResponse { + output: { + league: SportmonksLeague | null + } +} + +export const sportmonksGetLeagueTool: ToolConfig< + SportmonksGetLeagueParams, + SportmonksGetLeagueResponse +> = { + id: 'sportmonks_football_get_league', + name: 'Get League by ID', + description: 'Retrieve a single football league by its ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + leagueId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the league', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. country;currentSeason;seasons)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/leagues/${encodeURIComponent(params.leagueId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_league') + } + return { + success: true, + output: { + league: data.data ?? null, + }, + } + }, + + outputs: { + league: { + type: 'object', + description: 'The requested league object', + properties: SPORTMONKS_LEAGUE_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_leagues.ts b/apps/sim/tools/sportmonks_football/get_leagues.ts new file mode 100644 index 00000000000..dc66a7bc991 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_leagues.ts @@ -0,0 +1,105 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_LEAGUE_PROPERTIES, + type SportmonksLeague, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetLeaguesParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetLeaguesResponse extends ToolResponse { + output: { + leagues: SportmonksLeague[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksGetLeaguesTool: ToolConfig< + SportmonksGetLeaguesParams, + SportmonksGetLeaguesResponse +> = { + id: 'sportmonks_football_get_leagues', + name: 'Get Leagues', + description: 'Retrieve all football leagues available within your Sportmonks subscription', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. country;currentSeason)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order leagues (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_FOOTBALL_BASE_URL}/leagues`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_leagues') + } + return { + success: true, + output: { + leagues: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + leagues: { + type: 'array', + description: 'Array of league objects', + items: { type: 'object', properties: SPORTMONKS_LEAGUE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_livescores.ts b/apps/sim/tools/sportmonks_football/get_livescores.ts new file mode 100644 index 00000000000..4f20e769623 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_livescores.ts @@ -0,0 +1,80 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FIXTURE_PROPERTIES, + SPORTMONKS_FOOTBALL_BASE_URL, + type SportmonksFixture, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetLivescoresParams extends SportmonksBaseParams {} + +export interface SportmonksGetLivescoresResponse extends ToolResponse { + output: { + fixtures: SportmonksFixture[] + } +} + +export const sportmonksGetLivescoresTool: ToolConfig< + SportmonksGetLivescoresParams, + SportmonksGetLivescoresResponse +> = { + id: 'sportmonks_football_get_livescores', + name: 'Get Livescores', + description: + 'Retrieve fixtures starting within 15 minutes and currently in progress from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;scores;events)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. fixtureLeagues:501)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_FOOTBALL_BASE_URL}/livescores`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_livescores') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of live fixture objects', + items: { type: 'object', properties: SPORTMONKS_FIXTURE_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_player.ts b/apps/sim/tools/sportmonks_football/get_player.ts new file mode 100644 index 00000000000..301c0d61054 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_player.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_PLAYER_PROPERTIES, + type SportmonksPlayer, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetPlayerParams extends SportmonksBaseParams { + playerId: string +} + +export interface SportmonksGetPlayerResponse extends ToolResponse { + output: { + player: SportmonksPlayer | null + } +} + +export const sportmonksGetPlayerTool: ToolConfig< + SportmonksGetPlayerParams, + SportmonksGetPlayerResponse +> = { + id: 'sportmonks_football_get_player', + name: 'Get Player by ID', + description: 'Retrieve a single football player by their ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + playerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the player', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. country;position;teams.team;statistics)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/players/${encodeURIComponent(params.playerId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_player') + } + return { + success: true, + output: { + player: data.data ?? null, + }, + } + }, + + outputs: { + player: { + type: 'object', + description: 'The requested player object', + properties: SPORTMONKS_PLAYER_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_standings_by_season.ts b/apps/sim/tools/sportmonks_football/get_standings_by_season.ts new file mode 100644 index 00000000000..b1469fc6953 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_standings_by_season.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_STANDING_PROPERTIES, + type SportmonksStanding, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetStandingsBySeasonParams extends SportmonksBaseParams { + seasonId: string +} + +export interface SportmonksGetStandingsBySeasonResponse extends ToolResponse { + output: { + standings: SportmonksStanding[] + } +} + +export const sportmonksGetStandingsBySeasonTool: ToolConfig< + SportmonksGetStandingsBySeasonParams, + SportmonksGetStandingsBySeasonResponse +> = { + id: 'sportmonks_football_get_standings_by_season', + name: 'Get Standings by Season', + description: 'Retrieve the full league standings table for a season by season ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + seasonId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the season', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participant;details;form)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. standingStages:77453568)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/standings/seasons/${encodeURIComponent(params.seasonId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_standings_by_season') + } + return { + success: true, + output: { + standings: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + standings: { + type: 'array', + description: 'Array of standing entries for the season', + items: { type: 'object', properties: SPORTMONKS_STANDING_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_team.ts b/apps/sim/tools/sportmonks_football/get_team.ts new file mode 100644 index 00000000000..7bc4e391114 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_team.ts @@ -0,0 +1,88 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_TEAM_PROPERTIES, + type SportmonksTeam, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTeamParams extends SportmonksBaseParams { + teamId: string +} + +export interface SportmonksGetTeamResponse extends ToolResponse { + output: { + team: SportmonksTeam | null + } +} + +export const sportmonksGetTeamTool: ToolConfig = + { + id: 'sportmonks_football_get_team', + name: 'Get Team by ID', + description: 'Retrieve a single football team by its ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + teamId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the team', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. country;venue;coaches;players.player)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/teams/${encodeURIComponent(params.teamId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_team') + } + return { + success: true, + output: { + team: data.data ?? null, + }, + } + }, + + outputs: { + team: { + type: 'object', + description: 'The requested team object', + properties: SPORTMONKS_TEAM_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/sportmonks_football/get_team_squad.ts b/apps/sim/tools/sportmonks_football/get_team_squad.ts new file mode 100644 index 00000000000..c1a5c31d0a3 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_team_squad.ts @@ -0,0 +1,89 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_SQUAD_PROPERTIES, + type SportmonksSquadEntry, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTeamSquadParams extends SportmonksBaseParams { + teamId: string +} + +export interface SportmonksGetTeamSquadResponse extends ToolResponse { + output: { + squad: SportmonksSquadEntry[] + } +} + +export const sportmonksGetTeamSquadTool: ToolConfig< + SportmonksGetTeamSquadParams, + SportmonksGetTeamSquadResponse +> = { + id: 'sportmonks_football_get_team_squad', + name: 'Get Team Squad', + description: 'Retrieve the current domestic squad for a team by team ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + teamId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the team', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. player;position)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/squads/teams/${encodeURIComponent(params.teamId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_team_squad') + } + return { + success: true, + output: { + squad: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + squad: { + type: 'array', + description: 'Array of squad entries for the team', + items: { type: 'object', properties: SPORTMONKS_SQUAD_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_football/get_topscorers_by_season.ts b/apps/sim/tools/sportmonks_football/get_topscorers_by_season.ts new file mode 100644 index 00000000000..036f909c575 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/get_topscorers_by_season.ts @@ -0,0 +1,117 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_TOPSCORER_PROPERTIES, + type SportmonksTopscorer, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetTopscorersBySeasonParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + seasonId: string +} + +export interface SportmonksGetTopscorersBySeasonResponse extends ToolResponse { + output: { + topscorers: SportmonksTopscorer[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksGetTopscorersBySeasonTool: ToolConfig< + SportmonksGetTopscorersBySeasonParams, + SportmonksGetTopscorersBySeasonResponse +> = { + id: 'sportmonks_football_get_topscorers_by_season', + name: 'Get Topscorers by Season', + description: + 'Retrieve the topscorers (goals, assists, cards) for a season by season ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + seasonId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the season', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. player;participant;type)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. seasontopscorerTypes:208)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order topscorers by position (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/topscorers/seasons/${encodeURIComponent(params.seasonId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_topscorers_by_season') + } + return { + success: true, + output: { + topscorers: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + topscorers: { + type: 'array', + description: 'Array of topscorer entries for the season', + items: { type: 'object', properties: SPORTMONKS_TOPSCORER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/index.ts b/apps/sim/tools/sportmonks_football/index.ts new file mode 100644 index 00000000000..1b07764325d --- /dev/null +++ b/apps/sim/tools/sportmonks_football/index.ts @@ -0,0 +1,15 @@ +export { sportmonksGetFixtureTool } from './get_fixture' +export { sportmonksGetFixturesByDateTool } from './get_fixtures_by_date' +export { sportmonksGetFixturesByDateRangeTool } from './get_fixtures_by_date_range' +export { sportmonksGetHeadToHeadTool } from './get_head_to_head' +export { sportmonksGetInplayLivescoresTool } from './get_inplay_livescores' +export { sportmonksGetLeagueTool } from './get_league' +export { sportmonksGetLeaguesTool } from './get_leagues' +export { sportmonksGetLivescoresTool } from './get_livescores' +export { sportmonksGetPlayerTool } from './get_player' +export { sportmonksGetStandingsBySeasonTool } from './get_standings_by_season' +export { sportmonksGetTeamTool } from './get_team' +export { sportmonksGetTeamSquadTool } from './get_team_squad' +export { sportmonksGetTopscorersBySeasonTool } from './get_topscorers_by_season' +export { sportmonksSearchPlayersTool } from './search_players' +export { sportmonksSearchTeamsTool } from './search_teams' diff --git a/apps/sim/tools/sportmonks_football/search_players.ts b/apps/sim/tools/sportmonks_football/search_players.ts new file mode 100644 index 00000000000..6987e4185ab --- /dev/null +++ b/apps/sim/tools/sportmonks_football/search_players.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_PLAYER_PROPERTIES, + type SportmonksPlayer, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchPlayersParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchPlayersResponse extends ToolResponse { + output: { + players: SportmonksPlayer[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksSearchPlayersTool: ToolConfig< + SportmonksSearchPlayersParams, + SportmonksSearchPlayersResponse +> = { + id: 'sportmonks_football_search_players', + name: 'Search Players', + description: 'Search for football players by name from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The player name to search for (e.g. Tavernier)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. country;position;teams.team)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order players by id (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/players/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_players') + } + return { + success: true, + output: { + players: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + players: { + type: 'array', + description: 'Array of player objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_PLAYER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/search_teams.ts b/apps/sim/tools/sportmonks_football/search_teams.ts new file mode 100644 index 00000000000..2a9eaf7507f --- /dev/null +++ b/apps/sim/tools/sportmonks_football/search_teams.ts @@ -0,0 +1,115 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_FOOTBALL_BASE_URL, + SPORTMONKS_TEAM_PROPERTIES, + type SportmonksTeam, +} from '@/tools/sportmonks_football/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchTeamsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchTeamsResponse extends ToolResponse { + output: { + teams: SportmonksTeam[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksSearchTeamsTool: ToolConfig< + SportmonksSearchTeamsParams, + SportmonksSearchTeamsResponse +> = { + id: 'sportmonks_football_search_teams', + name: 'Search Teams', + description: 'Search for football teams by name from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The team name to search for (e.g. Celtic)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;venue)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order teams by id (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_FOOTBALL_BASE_URL}/teams/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_teams') + } + return { + success: true, + output: { + teams: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + teams: { + type: 'array', + description: 'Array of team objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_TEAM_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_football/types.ts b/apps/sim/tools/sportmonks_football/types.ts new file mode 100644 index 00000000000..47343ec28f4 --- /dev/null +++ b/apps/sim/tools/sportmonks_football/types.ts @@ -0,0 +1,392 @@ +import type { OutputProperty } from '@/tools/types' + +/** + * Base URL for the Sportmonks Football API v3. + * @see https://docs.sportmonks.com/v3/welcome/authentication + */ +export const SPORTMONKS_FOOTBALL_BASE_URL = 'https://api.sportmonks.com/v3/football' + +/** + * Output property definitions for a Fixture object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/fixture + */ +export const SPORTMONKS_FIXTURE_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the fixture' }, + sport_id: { type: 'number', description: 'Sport the fixture is played at' }, + league_id: { type: 'number', description: 'League the fixture is played in' }, + season_id: { type: 'number', description: 'Season the fixture is played in' }, + stage_id: { type: 'number', description: 'Stage the fixture is played in' }, + group_id: { type: 'number', description: 'Group the fixture is played in', nullable: true }, + aggregate_id: { type: 'number', description: 'Aggregate the fixture belongs to', nullable: true }, + round_id: { type: 'number', description: 'Round the fixture is played in', nullable: true }, + state_id: { type: 'number', description: 'State (status) of the fixture' }, + venue_id: { type: 'number', description: 'Venue the fixture is played at', nullable: true }, + name: { type: 'string', description: 'Name of the fixture (participants)', nullable: true }, + starting_at: { type: 'string', description: 'Datetime the fixture starts', nullable: true }, + result_info: { + type: 'string', + description: 'Final result summary', + nullable: true, + optional: true, + }, + leg: { type: 'string', description: 'Leg of the fixture (e.g. 1/1)', optional: true }, + details: { + type: 'string', + description: 'Details about the fixture', + nullable: true, + optional: true, + }, + length: { + type: 'number', + description: 'Length of the fixture in minutes', + nullable: true, + optional: true, + }, + placeholder: { + type: 'boolean', + description: 'Whether the fixture is a placeholder', + optional: true, + }, + has_odds: { type: 'boolean', description: 'Whether odds are available', optional: true }, + has_premium_odds: { + type: 'boolean', + description: 'Whether premium odds are available', + optional: true, + }, + starting_at_timestamp: { + type: 'number', + description: 'UNIX timestamp of the start time', + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Team object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/team-player-squad-coach-and-referee + */ +export const SPORTMONKS_TEAM_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the team' }, + sport_id: { type: 'number', description: 'Sport of the team' }, + country_id: { type: 'number', description: 'Country of the team' }, + venue_id: { + type: 'number', + description: 'Home venue of the team', + nullable: true, + optional: true, + }, + gender: { type: 'string', description: 'Gender of the team', optional: true }, + name: { type: 'string', description: 'Name of the team' }, + short_code: { + type: 'string', + description: 'Short code of the team', + nullable: true, + optional: true, + }, + image_path: { type: 'string', description: 'URL to the team logo', optional: true }, + founded: { + type: 'number', + description: 'Founding year of the team', + nullable: true, + optional: true, + }, + type: { type: 'string', description: 'Type of the team', optional: true }, + placeholder: { + type: 'boolean', + description: 'Whether the team is a placeholder', + optional: true, + }, + last_played_at: { + type: 'string', + description: 'Date and time of the last played match', + nullable: true, + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Player object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/team-player-squad-coach-and-referee + */ +export const SPORTMONKS_PLAYER_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the player' }, + sport_id: { type: 'number', description: 'Sport of the player' }, + country_id: { type: 'number', description: 'Country of birth of the player', nullable: true }, + nationality_id: { type: 'number', description: 'Nationality of the player', nullable: true }, + city_id: { + type: 'number', + description: 'City of birth of the player', + nullable: true, + optional: true, + }, + position_id: { + type: 'number', + description: 'Position of the player', + nullable: true, + optional: true, + }, + detailed_position_id: { + type: 'number', + description: 'Detailed position of the player', + nullable: true, + optional: true, + }, + type_id: { type: 'number', description: 'Type of the player', nullable: true, optional: true }, + common_name: { type: 'string', description: 'Name the player is known for', optional: true }, + firstname: { type: 'string', description: 'First name of the player', optional: true }, + lastname: { type: 'string', description: 'Last name of the player', optional: true }, + name: { type: 'string', description: 'Name of the player' }, + display_name: { type: 'string', description: 'Display name of the player', optional: true }, + image_path: { type: 'string', description: 'URL to the player headshot', optional: true }, + height: { + type: 'number', + description: 'Height of the player in cm', + nullable: true, + optional: true, + }, + weight: { + type: 'number', + description: 'Weight of the player in kg', + nullable: true, + optional: true, + }, + date_of_birth: { + type: 'string', + description: 'Date of birth of the player', + nullable: true, + optional: true, + }, + gender: { type: 'string', description: 'Gender of the player', optional: true }, +} as const satisfies Record + +/** + * Output property definitions for a League object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/league-season-schedule-stage-and-round + */ +export const SPORTMONKS_LEAGUE_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the league' }, + sport_id: { type: 'number', description: 'Sport of the league' }, + country_id: { type: 'number', description: 'Country of the league' }, + name: { type: 'string', description: 'Name of the league' }, + active: { + type: 'number', + description: 'Whether the league is active (1) or inactive (0)', + optional: true, + }, + short_code: { + type: 'string', + description: 'Short code of the league', + nullable: true, + optional: true, + }, + image_path: { type: 'string', description: 'URL to the league logo', optional: true }, + type: { type: 'string', description: 'Type of the league', optional: true }, + sub_type: { type: 'string', description: 'Subtype of the league', optional: true }, + last_played_at: { + type: 'string', + description: 'Date the last fixture was played', + nullable: true, + optional: true, + }, + category: { + type: 'number', + description: 'Importance category of the league (1-4)', + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Standing object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/standing-and-topscorer + */ +export const SPORTMONKS_STANDING_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the standing' }, + participant_id: { type: 'number', description: 'Team related to the standing' }, + sport_id: { type: 'number', description: 'Sport related to the standing' }, + league_id: { type: 'number', description: 'League related to the standing' }, + season_id: { type: 'number', description: 'Season related to the standing' }, + stage_id: { type: 'number', description: 'Stage related to the standing' }, + group_id: { type: 'number', description: 'Group related to the standing', nullable: true }, + round_id: { type: 'number', description: 'Round related to the standing', nullable: true }, + standing_rule_id: { + type: 'number', + description: 'Standing rule related to the standing', + optional: true, + }, + position: { type: 'number', description: 'Position of the team in the standing' }, + result: { type: 'string', description: 'Movement of the team in the standing', optional: true }, + points: { type: 'number', description: 'Points the team has gathered' }, +} as const satisfies Record + +/** + * Output property definitions for a Topscorer object. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/standing-and-topscorer + */ +export const SPORTMONKS_TOPSCORER_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the topscorer record' }, + season_id: { type: 'number', description: 'Season related to the topscorer' }, + league_id: { type: 'number', description: 'League related to the topscorer', optional: true }, + stage_id: { type: 'number', description: 'Stage related to the topscorer', optional: true }, + player_id: { type: 'number', description: 'Player related to the topscorer' }, + participant_id: { type: 'number', description: 'Team related to the topscorer' }, + type_id: { type: 'number', description: 'Type of the topscorer (goals, assists, cards)' }, + position: { type: 'number', description: 'Position of the topscorer' }, + total: { type: 'number', description: 'Number of goals, assists or cards' }, +} as const satisfies Record + +/** + * Output property definitions for a Team Squad entry. + * @see https://docs.sportmonks.com/v3/endpoints-and-entities/entities/team-player-squad-coach-and-referee + */ +export const SPORTMONKS_SQUAD_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the squad record' }, + transfer_id: { + type: 'number', + description: 'Transfer id of the squad record', + nullable: true, + optional: true, + }, + player_id: { type: 'number', description: 'Player in the squad' }, + team_id: { type: 'number', description: 'Team of the squad' }, + position_id: { + type: 'number', + description: 'Position of the player in the squad', + nullable: true, + optional: true, + }, + detailed_position_id: { + type: 'number', + description: 'Detailed position of the player in the squad', + nullable: true, + optional: true, + }, + jersey_number: { + type: 'number', + description: 'Jersey number of the player', + nullable: true, + optional: true, + }, + start: { + type: 'string', + description: 'Start contract date of the player', + nullable: true, + optional: true, + }, + end: { + type: 'string', + description: 'End contract date of the player', + nullable: true, + optional: true, + }, +} as const satisfies Record + +export interface SportmonksFixture { + id: number + sport_id: number + league_id: number + season_id: number + stage_id: number + group_id: number | null + aggregate_id: number | null + round_id: number | null + state_id: number + venue_id: number | null + name: string | null + starting_at: string | null + result_info?: string | null + leg?: string + details?: string | null + length?: number | null + placeholder?: boolean + has_odds?: boolean + has_premium_odds?: boolean + starting_at_timestamp?: number +} + +export interface SportmonksTeam { + id: number + sport_id: number + country_id: number + venue_id?: number | null + gender?: string + name: string + short_code?: string | null + image_path?: string + founded?: number | null + type?: string + placeholder?: boolean + last_played_at?: string | null +} + +export interface SportmonksPlayer { + id: number + sport_id: number + country_id: number | null + nationality_id: number | null + city_id?: number | null + position_id?: number | null + detailed_position_id?: number | null + type_id?: number | null + common_name?: string + firstname?: string + lastname?: string + name: string + display_name?: string + image_path?: string + height?: number | null + weight?: number | null + date_of_birth?: string | null + gender?: string +} + +export interface SportmonksLeague { + id: number + sport_id: number + country_id: number + name: string + active?: number + short_code?: string | null + image_path?: string + type?: string + sub_type?: string + last_played_at?: string | null + category?: number +} + +export interface SportmonksStanding { + id: number + participant_id: number + sport_id: number + league_id: number + season_id: number + stage_id: number + group_id: number | null + round_id: number | null + standing_rule_id?: number + position: number + result?: string + points: number +} + +export interface SportmonksTopscorer { + id: number + season_id: number + league_id?: number + stage_id?: number + player_id: number + participant_id: number + type_id: number + position: number + total: number +} + +export interface SportmonksSquadEntry { + id: number + transfer_id?: number | null + player_id: number + team_id: number + position_id?: number | null + detailed_position_id?: number | null + jersey_number?: number | null + start?: string | null + end?: string | null +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_driver.ts b/apps/sim/tools/sportmonks_motorsport/get_driver.ts new file mode 100644 index 00000000000..0764250c619 --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_driver.ts @@ -0,0 +1,89 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_DRIVER_PROPERTIES, + type SportmonksMsDriver, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetDriverParams extends SportmonksBaseParams { + driverId: string +} + +export interface SportmonksMsGetDriverResponse extends ToolResponse { + output: { + driver: SportmonksMsDriver | null + } +} + +export const sportmonksMotorsportGetDriverTool: ToolConfig< + SportmonksMsGetDriverParams, + SportmonksMsGetDriverResponse +> = { + id: 'sportmonks_motorsport_get_driver', + name: 'Get Driver by ID', + description: 'Retrieve a single motorsport driver by their ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + driverId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the driver', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;teams)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/drivers/${encodeURIComponent(params.driverId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_driver') + } + return { + success: true, + output: { + driver: data.data ?? null, + }, + } + }, + + outputs: { + driver: { + type: 'object', + description: 'The requested driver object', + properties: SPORTMONKS_MS_DRIVER_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_driver_standings_by_season.ts b/apps/sim/tools/sportmonks_motorsport/get_driver_standings_by_season.ts new file mode 100644 index 00000000000..5f95c60228e --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_driver_standings_by_season.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_STANDING_PROPERTIES, + type SportmonksMsStanding, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetDriverStandingsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + seasonId: string +} + +export interface SportmonksMsGetDriverStandingsResponse extends ToolResponse { + output: { + standings: SportmonksMsStanding[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetDriverStandingsBySeasonTool: ToolConfig< + SportmonksMsGetDriverStandingsParams, + SportmonksMsGetDriverStandingsResponse +> = { + id: 'sportmonks_motorsport_get_driver_standings_by_season', + name: 'Get Driver Standings by Season', + description: + 'Retrieve the drivers championship standings for a season by season ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + seasonId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the season', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. participant;season)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/standings/drivers/seasons/${encodeURIComponent(params.seasonId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_driver_standings_by_season') + } + return { + success: true, + output: { + standings: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + standings: { + type: 'array', + description: 'Array of driver standing entries for the season', + items: { type: 'object', properties: SPORTMONKS_MS_STANDING_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_drivers.ts b/apps/sim/tools/sportmonks_motorsport/get_drivers.ts new file mode 100644 index 00000000000..722a7e2742c --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_drivers.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_DRIVER_PROPERTIES, + type SportmonksMsDriver, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetDriversParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksMsGetDriversResponse extends ToolResponse { + output: { + drivers: SportmonksMsDriver[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetDriversTool: ToolConfig< + SportmonksMsGetDriversParams, + SportmonksMsGetDriversResponse +> = { + id: 'sportmonks_motorsport_get_drivers', + name: 'Get Drivers', + description: 'Retrieve all motorsport drivers from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;teams)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_MOTORSPORT_BASE_URL}/drivers`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_drivers') + } + return { + success: true, + output: { + drivers: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + drivers: { + type: 'array', + description: 'Array of driver objects', + items: { type: 'object', properties: SPORTMONKS_MS_DRIVER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_fixture.ts b/apps/sim/tools/sportmonks_motorsport/get_fixture.ts new file mode 100644 index 00000000000..f62c25025ea --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_fixture.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_FIXTURE_PROPERTIES, + type SportmonksMsFixture, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetFixtureParams extends SportmonksBaseParams { + fixtureId: string +} + +export interface SportmonksMsGetFixtureResponse extends ToolResponse { + output: { + fixture: SportmonksMsFixture | null + } +} + +export const sportmonksMotorsportGetFixtureTool: ToolConfig< + SportmonksMsGetFixtureParams, + SportmonksMsGetFixtureResponse +> = { + id: 'sportmonks_motorsport_get_fixture', + name: 'Get Motorsport Fixture by ID', + description: 'Retrieve a single motorsport fixture (session) by its ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture (session)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;results;latestLaps;pitstops)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/fixtures/${encodeURIComponent(params.fixtureId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_fixture') + } + return { + success: true, + output: { + fixture: data.data ?? null, + }, + } + }, + + outputs: { + fixture: { + type: 'object', + description: 'The requested motorsport fixture (session) object', + properties: SPORTMONKS_MS_FIXTURE_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_fixtures_by_date.ts b/apps/sim/tools/sportmonks_motorsport/get_fixtures_by_date.ts new file mode 100644 index 00000000000..6a3a620797c --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_fixtures_by_date.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_FIXTURE_PROPERTIES, + type SportmonksMsFixture, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetFixturesByDateParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + date: string +} + +export interface SportmonksMsGetFixturesByDateResponse extends ToolResponse { + output: { + fixtures: SportmonksMsFixture[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetFixturesByDateTool: ToolConfig< + SportmonksMsGetFixturesByDateParams, + SportmonksMsGetFixturesByDateResponse +> = { + id: 'sportmonks_motorsport_get_fixtures_by_date', + name: 'Get Motorsport Fixtures by Date', + description: + 'Retrieve motorsport fixtures (sessions) on a specific date (YYYY-MM-DD) from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + date: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The date to fetch fixtures for, in YYYY-MM-DD format', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. participants;venue)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order fixtures by starting_at (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/fixtures/date/${encodeURIComponent(params.date.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_fixtures_by_date') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of motorsport fixture (session) objects for the requested date', + items: { type: 'object', properties: SPORTMONKS_MS_FIXTURE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_laps_by_fixture.ts b/apps/sim/tools/sportmonks_motorsport/get_laps_by_fixture.ts new file mode 100644 index 00000000000..ffa114b8083 --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_laps_by_fixture.ts @@ -0,0 +1,90 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_LAP_PROPERTIES, + type SportmonksMsLap, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetLapsByFixtureParams extends SportmonksBaseParams { + fixtureId: string +} + +export interface SportmonksMsGetLapsByFixtureResponse extends ToolResponse { + output: { + laps: SportmonksMsLap[] + } +} + +export const sportmonksMotorsportGetLapsByFixtureTool: ToolConfig< + SportmonksMsGetLapsByFixtureParams, + SportmonksMsGetLapsByFixtureResponse +> = { + id: 'sportmonks_motorsport_get_laps_by_fixture', + name: 'Get Laps by Fixture', + description: 'Retrieve all laps for a motorsport fixture (session) by fixture ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture (session)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participant;details)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/fixtures/${encodeURIComponent(params.fixtureId.trim())}/laps` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_laps_by_fixture') + } + return { + success: true, + output: { + laps: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + laps: { + type: 'array', + description: 'Array of lap objects for the fixture', + items: { type: 'object', properties: SPORTMONKS_MS_LAP_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_livescores.ts b/apps/sim/tools/sportmonks_motorsport/get_livescores.ts new file mode 100644 index 00000000000..781430a89e8 --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_livescores.ts @@ -0,0 +1,105 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_FIXTURE_PROPERTIES, + type SportmonksMsFixture, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetLivescoresParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksMsGetLivescoresResponse extends ToolResponse { + output: { + fixtures: SportmonksMsFixture[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetLivescoresTool: ToolConfig< + SportmonksMsGetLivescoresParams, + SportmonksMsGetLivescoresResponse +> = { + id: 'sportmonks_motorsport_get_livescores', + name: 'Get Motorsport Livescores', + description: 'Retrieve all live motorsport fixtures (sessions) from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participants;results)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_MOTORSPORT_BASE_URL}/livescores`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_livescores') + } + return { + success: true, + output: { + fixtures: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + fixtures: { + type: 'array', + description: 'Array of live motorsport fixture (session) objects', + items: { type: 'object', properties: SPORTMONKS_MS_FIXTURE_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_pitstops_by_fixture.ts b/apps/sim/tools/sportmonks_motorsport/get_pitstops_by_fixture.ts new file mode 100644 index 00000000000..367c0f079c2 --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_pitstops_by_fixture.ts @@ -0,0 +1,91 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_LAP_PROPERTIES, + type SportmonksMsLap, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetPitstopsByFixtureParams extends SportmonksBaseParams { + fixtureId: string +} + +export interface SportmonksMsGetPitstopsByFixtureResponse extends ToolResponse { + output: { + pitstops: SportmonksMsLap[] + } +} + +export const sportmonksMotorsportGetPitstopsByFixtureTool: ToolConfig< + SportmonksMsGetPitstopsByFixtureParams, + SportmonksMsGetPitstopsByFixtureResponse +> = { + id: 'sportmonks_motorsport_get_pitstops_by_fixture', + name: 'Get Pitstops by Fixture', + description: + 'Retrieve all pitstops for a motorsport fixture (session) by fixture ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture (session)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Semicolon-separated relations to enrich the response (e.g. participant;details)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/fixtures/${encodeURIComponent(params.fixtureId.trim())}/pitstops` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_pitstops_by_fixture') + } + return { + success: true, + output: { + pitstops: Array.isArray(data.data) ? data.data : [], + }, + } + }, + + outputs: { + pitstops: { + type: 'array', + description: 'Array of pitstop objects for the fixture', + items: { type: 'object', properties: SPORTMONKS_MS_LAP_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_team.ts b/apps/sim/tools/sportmonks_motorsport/get_team.ts new file mode 100644 index 00000000000..e177e6c5624 --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_team.ts @@ -0,0 +1,89 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_TEAM_PROPERTIES, + type SportmonksMsTeam, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetTeamParams extends SportmonksBaseParams { + teamId: string +} + +export interface SportmonksMsGetTeamResponse extends ToolResponse { + output: { + team: SportmonksMsTeam | null + } +} + +export const sportmonksMotorsportGetTeamTool: ToolConfig< + SportmonksMsGetTeamParams, + SportmonksMsGetTeamResponse +> = { + id: 'sportmonks_motorsport_get_team', + name: 'Get Team by ID', + description: 'Retrieve a single motorsport team (constructor) by its ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + teamId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the team (constructor)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;drivers)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/teams/${encodeURIComponent(params.teamId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_team') + } + return { + success: true, + output: { + team: data.data ?? null, + }, + } + }, + + outputs: { + team: { + type: 'object', + description: 'The requested team (constructor) object', + properties: SPORTMONKS_MS_TEAM_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_team_standings_by_season.ts b/apps/sim/tools/sportmonks_motorsport/get_team_standings_by_season.ts new file mode 100644 index 00000000000..7a6e90f9b9b --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_team_standings_by_season.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_STANDING_PROPERTIES, + type SportmonksMsStanding, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetTeamStandingsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + seasonId: string +} + +export interface SportmonksMsGetTeamStandingsResponse extends ToolResponse { + output: { + standings: SportmonksMsStanding[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetTeamStandingsBySeasonTool: ToolConfig< + SportmonksMsGetTeamStandingsParams, + SportmonksMsGetTeamStandingsResponse +> = { + id: 'sportmonks_motorsport_get_team_standings_by_season', + name: 'Get Team Standings by Season', + description: + 'Retrieve the constructors championship standings for a season by season ID from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + seasonId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the season', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. participant;season)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/standings/teams/seasons/${encodeURIComponent(params.seasonId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_team_standings_by_season') + } + return { + success: true, + output: { + standings: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + standings: { + type: 'array', + description: 'Array of team (constructor) standing entries for the season', + items: { type: 'object', properties: SPORTMONKS_MS_STANDING_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/get_teams.ts b/apps/sim/tools/sportmonks_motorsport/get_teams.ts new file mode 100644 index 00000000000..137a2a5c4fb --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/get_teams.ts @@ -0,0 +1,104 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_TEAM_PROPERTIES, + type SportmonksMsTeam, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsGetTeamsParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksMsGetTeamsResponse extends ToolResponse { + output: { + teams: SportmonksMsTeam[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportGetTeamsTool: ToolConfig< + SportmonksMsGetTeamsParams, + SportmonksMsGetTeamsResponse +> = { + id: 'sportmonks_motorsport_get_teams', + name: 'Get Teams', + description: 'Retrieve all motorsport teams (constructors) from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;drivers)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_MOTORSPORT_BASE_URL}/teams`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_teams') + } + return { + success: true, + output: { + teams: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + teams: { + type: 'array', + description: 'Array of team (constructor) objects', + items: { type: 'object', properties: SPORTMONKS_MS_TEAM_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/index.ts b/apps/sim/tools/sportmonks_motorsport/index.ts new file mode 100644 index 00000000000..acc0a9846ba --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/index.ts @@ -0,0 +1,12 @@ +export { sportmonksMotorsportGetDriverTool } from './get_driver' +export { sportmonksMotorsportGetDriverStandingsBySeasonTool } from './get_driver_standings_by_season' +export { sportmonksMotorsportGetDriversTool } from './get_drivers' +export { sportmonksMotorsportGetFixtureTool } from './get_fixture' +export { sportmonksMotorsportGetFixturesByDateTool } from './get_fixtures_by_date' +export { sportmonksMotorsportGetLapsByFixtureTool } from './get_laps_by_fixture' +export { sportmonksMotorsportGetLivescoresTool } from './get_livescores' +export { sportmonksMotorsportGetPitstopsByFixtureTool } from './get_pitstops_by_fixture' +export { sportmonksMotorsportGetTeamTool } from './get_team' +export { sportmonksMotorsportGetTeamStandingsBySeasonTool } from './get_team_standings_by_season' +export { sportmonksMotorsportGetTeamsTool } from './get_teams' +export { sportmonksMotorsportSearchDriversTool } from './search_drivers' diff --git a/apps/sim/tools/sportmonks_motorsport/search_drivers.ts b/apps/sim/tools/sportmonks_motorsport/search_drivers.ts new file mode 100644 index 00000000000..2680b01e0ff --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/search_drivers.ts @@ -0,0 +1,109 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MOTORSPORT_BASE_URL, + SPORTMONKS_MS_DRIVER_PROPERTIES, + type SportmonksMsDriver, +} from '@/tools/sportmonks_motorsport/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksMsSearchDriversParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksMsSearchDriversResponse extends ToolResponse { + output: { + drivers: SportmonksMsDriver[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksMotorsportSearchDriversTool: ToolConfig< + SportmonksMsSearchDriversParams, + SportmonksMsSearchDriversResponse +> = { + id: 'sportmonks_motorsport_search_drivers', + name: 'Search Drivers', + description: 'Search for motorsport drivers by name from Sportmonks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The driver name to search for (e.g. Verstappen)', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. country;teams)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_MOTORSPORT_BASE_URL}/drivers/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_drivers') + } + return { + success: true, + output: { + drivers: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + drivers: { + type: 'array', + description: 'Array of driver objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_MS_DRIVER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_motorsport/types.ts b/apps/sim/tools/sportmonks_motorsport/types.ts new file mode 100644 index 00000000000..51947e9e6ba --- /dev/null +++ b/apps/sim/tools/sportmonks_motorsport/types.ts @@ -0,0 +1,317 @@ +import type { OutputProperty } from '@/tools/types' + +/** + * Base URL for the Sportmonks Motorsport API v3. + * @see https://docs.sportmonks.com/v3/motorsport-api/welcome/welcome + */ +export const SPORTMONKS_MOTORSPORT_BASE_URL = 'https://api.sportmonks.com/v3/motorsport' + +/** + * Output property definitions for a Motorsport Fixture (session) object. + * @see https://docs.sportmonks.com/v3/motorsport-api/endpoints-and-entities/entities/fixture + */ +export const SPORTMONKS_MS_FIXTURE_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the fixture (session)' }, + sport_id: { type: 'number', description: 'Sport of the fixture' }, + league_id: { type: 'number', description: 'League the fixture is held in' }, + season_id: { type: 'number', description: 'Season the fixture is held in' }, + stage_id: { type: 'number', description: 'Stage (race weekend) the fixture is held in' }, + group_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + aggregate_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + round_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + state_id: { type: 'number', description: 'State the fixture is currently in' }, + venue_id: { type: 'number', description: 'Venue (track) the fixture is held at', nullable: true }, + name: { + type: 'string', + description: 'Name of the fixture (e.g. Practice 1, Race)', + nullable: true, + }, + starting_at: { type: 'string', description: 'Start date and time', nullable: true }, + result_info: { type: 'string', description: 'Final result info', nullable: true, optional: true }, + leg: { + type: 'string', + description: 'Stage of the fixture (e.g. 2/3 for Practice 2)', + optional: true, + }, + details: { + type: 'string', + description: 'Details about the fixture', + nullable: true, + optional: true, + }, + length: { + type: 'number', + description: 'Session length in minutes or total laps', + nullable: true, + optional: true, + }, + placeholder: { + type: 'boolean', + description: 'Whether the fixture is a placeholder', + optional: true, + }, + has_odds: { type: 'boolean', description: 'Not used in the Motorsport API', optional: true }, + has_premium_odds: { + type: 'boolean', + description: 'Not used in the Motorsport API', + optional: true, + }, + starting_at_timestamp: { + type: 'number', + description: 'UNIX timestamp of the start time', + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Driver object. + * @see https://docs.sportmonks.com/v3/motorsport-api/endpoints-and-entities/entities/driver + */ +export const SPORTMONKS_MS_DRIVER_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the driver (player_id in responses)' }, + sport_id: { type: 'number', description: 'Sport of the driver' }, + country_id: { type: 'number', description: 'Country of birth of the driver', nullable: true }, + nationality_id: { type: 'number', description: 'Nationality of the driver', nullable: true }, + city_id: { + type: 'number', + description: 'City of birth of the driver', + nullable: true, + optional: true, + }, + position_id: { + type: 'number', + description: 'Position of the driver within the team', + nullable: true, + optional: true, + }, + detailed_position_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + type_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + common_name: { type: 'string', description: 'Name the driver is known for', optional: true }, + firstname: { type: 'string', description: 'First name of the driver', optional: true }, + lastname: { type: 'string', description: 'Last name of the driver', optional: true }, + name: { type: 'string', description: 'Name of the driver' }, + display_name: { type: 'string', description: 'Display name of the driver', optional: true }, + image_path: { type: 'string', description: 'URL to the driver headshot', optional: true }, + height: { + type: 'number', + description: 'Height of the driver in cm', + nullable: true, + optional: true, + }, + weight: { + type: 'number', + description: 'Weight of the driver in kg', + nullable: true, + optional: true, + }, + date_of_birth: { + type: 'string', + description: 'Date of birth of the driver', + nullable: true, + optional: true, + }, + gender: { type: 'string', description: 'Gender of the driver', optional: true }, +} as const satisfies Record + +/** + * Output property definitions for a Motorsport Team (constructor) object. + * @see https://docs.sportmonks.com/v3/motorsport-api/endpoints-and-entities/entities/team + */ +export const SPORTMONKS_MS_TEAM_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the team' }, + sport_id: { type: 'number', description: 'Sport of the team' }, + country_id: { type: 'number', description: 'Country of the team' }, + venue_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + gender: { type: 'string', description: 'Gender of the team', optional: true }, + name: { type: 'string', description: 'Name of the team (constructor)' }, + short_code: { + type: 'string', + description: 'Short code of the team', + nullable: true, + optional: true, + }, + image_path: { type: 'string', description: 'URL to the team logo', optional: true }, + founded: { + type: 'number', + description: 'Founding year of the team', + nullable: true, + optional: true, + }, + type: { type: 'string', description: 'Type of the team', optional: true }, + placeholder: { + type: 'boolean', + description: 'Whether the team is a placeholder', + optional: true, + }, + last_played_at: { + type: 'string', + description: "Date and time of the team's last session", + nullable: true, + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Motorsport Standing object. + * @see https://docs.sportmonks.com/v3/motorsport-api/endpoints-and-entities/entities/standing + */ +export const SPORTMONKS_MS_STANDING_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the standing' }, + participant_id: { type: 'number', description: 'Driver or team related to the standing' }, + sport_id: { type: 'number', description: 'Sport related to the standing' }, + league_id: { type: 'number', description: 'League related to the standing' }, + season_id: { type: 'number', description: 'Season related to the standing' }, + stage_id: { type: 'number', description: 'Stage related to the standing' }, + group_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + round_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + standing_rule_id: { + type: 'number', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + position: { type: 'number', description: 'Position of the participant in the standing' }, + result: { + type: 'string', + description: 'Not used in the Motorsport API', + nullable: true, + optional: true, + }, + points: { type: 'number', description: 'Points the participant has gathered' }, +} as const satisfies Record + +/** + * Output property definitions for a Lap / Pitstop object (identical shape). + * @see https://docs.sportmonks.com/v3/motorsport-api/endpoints-and-entities/entities/lap + */ +export const SPORTMONKS_MS_LAP_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the lap/pitstop' }, + fixture_id: { type: 'number', description: 'Fixture related to the lap/pitstop' }, + lap_number: { type: 'number', description: 'Lap number in the fixture' }, + driver_number: { type: 'number', description: 'Number of the driver' }, + participant_id: { type: 'number', description: 'Driver related to the lap/pitstop' }, + is_latest: { type: 'boolean', description: 'Whether it is the latest lap/pitstop' }, +} as const satisfies Record + +export interface SportmonksMsFixture { + id: number + sport_id: number + league_id: number + season_id: number + stage_id: number + group_id?: number | null + aggregate_id?: number | null + round_id?: number | null + state_id: number + venue_id: number | null + name: string | null + starting_at: string | null + result_info?: string | null + leg?: string + details?: string | null + length?: number | null + placeholder?: boolean + has_odds?: boolean + has_premium_odds?: boolean + starting_at_timestamp?: number +} + +export interface SportmonksMsDriver { + id: number + sport_id: number + country_id: number | null + nationality_id: number | null + city_id?: number | null + position_id?: number | null + detailed_position_id?: number | null + type_id?: number | null + common_name?: string + firstname?: string + lastname?: string + name: string + display_name?: string + image_path?: string + height?: number | null + weight?: number | null + date_of_birth?: string | null + gender?: string +} + +export interface SportmonksMsTeam { + id: number + sport_id: number + country_id: number + venue_id?: number | null + gender?: string + name: string + short_code?: string | null + image_path?: string + founded?: number | null + type?: string + placeholder?: boolean + last_played_at?: string | null +} + +export interface SportmonksMsStanding { + id: number + participant_id: number + sport_id: number + league_id: number + season_id: number + stage_id: number + group_id?: number | null + round_id?: number | null + standing_rule_id?: number | null + position: number + result?: string | null + points: number +} + +export interface SportmonksMsLap { + id: number + fixture_id: number + lap_number: number + driver_number: number + participant_id: number + is_latest: boolean +} diff --git a/apps/sim/tools/sportmonks_odds/get_bookmaker.ts b/apps/sim/tools/sportmonks_odds/get_bookmaker.ts new file mode 100644 index 00000000000..b06ce7b7383 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_bookmaker.ts @@ -0,0 +1,74 @@ +import { + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_BOOKMAKER_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksBookmaker, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetBookmakerParams extends SportmonksBaseParams { + bookmakerId: string +} + +export interface SportmonksGetBookmakerResponse extends ToolResponse { + output: { + bookmaker: SportmonksBookmaker | null + } +} + +export const sportmonksOddsGetBookmakerTool: ToolConfig< + SportmonksGetBookmakerParams, + SportmonksGetBookmakerResponse +> = { + id: 'sportmonks_odds_get_bookmaker', + name: 'Get Bookmaker by ID', + description: 'Retrieve a single bookmaker by its ID from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + bookmakerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the bookmaker', + }, + }, + + request: { + url: (params) => + `${SPORTMONKS_ODDS_BASE_URL}/bookmakers/${encodeURIComponent(params.bookmakerId.trim())}`, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_bookmaker') + } + return { + success: true, + output: { + bookmaker: data.data ?? null, + }, + } + }, + + outputs: { + bookmaker: { + type: 'object', + description: 'The requested bookmaker object', + properties: SPORTMONKS_BOOKMAKER_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/get_bookmakers.ts b/apps/sim/tools/sportmonks_odds/get_bookmakers.ts new file mode 100644 index 00000000000..00d507d7728 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_bookmakers.ts @@ -0,0 +1,98 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_BOOKMAKER_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksBookmaker, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetBookmakersParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetBookmakersResponse extends ToolResponse { + output: { + bookmakers: SportmonksBookmaker[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsGetBookmakersTool: ToolConfig< + SportmonksGetBookmakersParams, + SportmonksGetBookmakersResponse +> = { + id: 'sportmonks_odds_get_bookmakers', + name: 'Get Bookmakers', + description: 'Retrieve all bookmakers from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. IdAfter:bookmakerID)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_ODDS_BASE_URL}/bookmakers`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_bookmakers') + } + return { + success: true, + output: { + bookmakers: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + bookmakers: { + type: 'array', + description: 'Array of bookmaker objects', + items: { type: 'object', properties: SPORTMONKS_BOOKMAKER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/get_inplay_odds_by_fixture.ts b/apps/sim/tools/sportmonks_odds/get_inplay_odds_by_fixture.ts new file mode 100644 index 00000000000..72f610a6b93 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_inplay_odds_by_fixture.ts @@ -0,0 +1,116 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_INPLAY_ODD_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksInplayOdd, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetInplayOddsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + fixtureId: string +} + +export interface SportmonksGetInplayOddsResponse extends ToolResponse { + output: { + odds: SportmonksInplayOdd[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsGetInplayOddsByFixtureTool: ToolConfig< + SportmonksGetInplayOddsParams, + SportmonksGetInplayOddsResponse +> = { + id: 'sportmonks_odds_get_inplay_odds_by_fixture', + name: 'Get In-play Odds by Fixture', + description: + 'Retrieve live (in-play) odds for a fixture by fixture ID from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. market;bookmaker)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. markets:1,12 or bookmakers:2,14 or winningOdds)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_ODDS_BASE_URL}/inplay/fixtures/${encodeURIComponent(params.fixtureId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_inplay_odds_by_fixture') + } + return { + success: true, + output: { + odds: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + odds: { + type: 'array', + description: 'Array of in-play odd objects for the fixture', + items: { type: 'object', properties: SPORTMONKS_INPLAY_ODD_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/get_market.ts b/apps/sim/tools/sportmonks_odds/get_market.ts new file mode 100644 index 00000000000..06c974ab19e --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_market.ts @@ -0,0 +1,74 @@ +import { + buildSportmonksHeaders, + handleSportmonksError, + type SportmonksBaseParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MARKET_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksMarket, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetMarketParams extends SportmonksBaseParams { + marketId: string +} + +export interface SportmonksGetMarketResponse extends ToolResponse { + output: { + market: SportmonksMarket | null + } +} + +export const sportmonksOddsGetMarketTool: ToolConfig< + SportmonksGetMarketParams, + SportmonksGetMarketResponse +> = { + id: 'sportmonks_odds_get_market', + name: 'Get Market by ID', + description: 'Retrieve a single betting market by its ID from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + marketId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the market', + }, + }, + + request: { + url: (params) => + `${SPORTMONKS_ODDS_BASE_URL}/markets/${encodeURIComponent(params.marketId.trim())}`, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_market') + } + return { + success: true, + output: { + market: data.data ?? null, + }, + } + }, + + outputs: { + market: { + type: 'object', + description: 'The requested market object', + properties: SPORTMONKS_MARKET_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/get_markets.ts b/apps/sim/tools/sportmonks_odds/get_markets.ts new file mode 100644 index 00000000000..620756b00e1 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_markets.ts @@ -0,0 +1,98 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MARKET_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksMarket, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetMarketsParams + extends SportmonksBaseParams, + SportmonksPaginationParams {} + +export interface SportmonksGetMarketsResponse extends ToolResponse { + output: { + markets: SportmonksMarket[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsGetMarketsTool: ToolConfig< + SportmonksGetMarketsParams, + SportmonksGetMarketsResponse +> = { + id: 'sportmonks_odds_get_markets', + name: 'Get Markets', + description: 'Retrieve all betting markets from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. IdAfter:marketID)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => appendSportmonksQuery(`${SPORTMONKS_ODDS_BASE_URL}/markets`, params), + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_markets') + } + return { + success: true, + output: { + markets: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + markets: { + type: 'array', + description: 'Array of market objects', + items: { type: 'object', properties: SPORTMONKS_MARKET_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/get_pre_match_odds_by_fixture.ts b/apps/sim/tools/sportmonks_odds/get_pre_match_odds_by_fixture.ts new file mode 100644 index 00000000000..f769d06bf63 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/get_pre_match_odds_by_fixture.ts @@ -0,0 +1,115 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_ODD_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksOdd, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksGetPreMatchOddsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + fixtureId: string +} + +export interface SportmonksGetPreMatchOddsResponse extends ToolResponse { + output: { + odds: SportmonksOdd[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsGetPreMatchOddsByFixtureTool: ToolConfig< + SportmonksGetPreMatchOddsParams, + SportmonksGetPreMatchOddsResponse +> = { + id: 'sportmonks_odds_get_pre_match_odds_by_fixture', + name: 'Get Pre-match Odds by Fixture', + description: 'Retrieve pre-match odds for a fixture by fixture ID from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + fixtureId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique id of the fixture', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Semicolon-separated relations to enrich the response (e.g. market;bookmaker)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filters to apply (e.g. markets:1,12 or bookmakers:2,14 or winningOdds)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + order: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order direction (asc or desc)', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_ODDS_BASE_URL}/pre-match/fixtures/${encodeURIComponent(params.fixtureId.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'get_pre_match_odds_by_fixture') + } + return { + success: true, + output: { + odds: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + odds: { + type: 'array', + description: 'Array of pre-match odd objects for the fixture', + items: { type: 'object', properties: SPORTMONKS_ODD_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/index.ts b/apps/sim/tools/sportmonks_odds/index.ts new file mode 100644 index 00000000000..ec81b21556e --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/index.ts @@ -0,0 +1,8 @@ +export { sportmonksOddsGetBookmakerTool } from './get_bookmaker' +export { sportmonksOddsGetBookmakersTool } from './get_bookmakers' +export { sportmonksOddsGetInplayOddsByFixtureTool } from './get_inplay_odds_by_fixture' +export { sportmonksOddsGetMarketTool } from './get_market' +export { sportmonksOddsGetMarketsTool } from './get_markets' +export { sportmonksOddsGetPreMatchOddsByFixtureTool } from './get_pre_match_odds_by_fixture' +export { sportmonksOddsSearchBookmakersTool } from './search_bookmakers' +export { sportmonksOddsSearchMarketsTool } from './search_markets' diff --git a/apps/sim/tools/sportmonks_odds/search_bookmakers.ts b/apps/sim/tools/sportmonks_odds/search_bookmakers.ts new file mode 100644 index 00000000000..dc03a8fafff --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/search_bookmakers.ts @@ -0,0 +1,97 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_BOOKMAKER_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksBookmaker, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchBookmakersParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchBookmakersResponse extends ToolResponse { + output: { + bookmakers: SportmonksBookmaker[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsSearchBookmakersTool: ToolConfig< + SportmonksSearchBookmakersParams, + SportmonksSearchBookmakersResponse +> = { + id: 'sportmonks_odds_search_bookmakers', + name: 'Search Bookmakers', + description: 'Search for bookmakers by name from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The bookmaker name to search for (e.g. bet365)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_ODDS_BASE_URL}/bookmakers/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_bookmakers') + } + return { + success: true, + output: { + bookmakers: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + bookmakers: { + type: 'array', + description: 'Array of bookmaker objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_BOOKMAKER_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/search_markets.ts b/apps/sim/tools/sportmonks_odds/search_markets.ts new file mode 100644 index 00000000000..dd85ef536e7 --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/search_markets.ts @@ -0,0 +1,97 @@ +import { + appendSportmonksQuery, + buildSportmonksHeaders, + handleSportmonksError, + SPORTMONKS_PAGINATION_OUTPUT, + type SportmonksBaseParams, + type SportmonksPagination, + type SportmonksPaginationParams, +} from '@/tools/sportmonks/types' +import { + SPORTMONKS_MARKET_PROPERTIES, + SPORTMONKS_ODDS_BASE_URL, + type SportmonksMarket, +} from '@/tools/sportmonks_odds/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface SportmonksSearchMarketsParams + extends SportmonksBaseParams, + SportmonksPaginationParams { + query: string +} + +export interface SportmonksSearchMarketsResponse extends ToolResponse { + output: { + markets: SportmonksMarket[] + pagination?: SportmonksPagination | null + } +} + +export const sportmonksOddsSearchMarketsTool: ToolConfig< + SportmonksSearchMarketsParams, + SportmonksSearchMarketsResponse +> = { + id: 'sportmonks_odds_search_markets', + name: 'Search Markets', + description: 'Search for betting markets by name from the Sportmonks Odds API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sportmonks API token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The market name to search for (e.g. Over/Under)', + }, + per_page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (max 50, default 25)', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number to retrieve', + }, + }, + + request: { + url: (params) => { + const url = `${SPORTMONKS_ODDS_BASE_URL}/markets/search/${encodeURIComponent(params.query.trim())}` + return appendSportmonksQuery(url, params) + }, + method: 'GET', + headers: (params) => buildSportmonksHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + handleSportmonksError(data, response.status, 'search_markets') + } + return { + success: true, + output: { + markets: Array.isArray(data.data) ? data.data : [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + markets: { + type: 'array', + description: 'Array of market objects matching the search query', + items: { type: 'object', properties: SPORTMONKS_MARKET_PROPERTIES }, + }, + pagination: SPORTMONKS_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/sportmonks_odds/types.ts b/apps/sim/tools/sportmonks_odds/types.ts new file mode 100644 index 00000000000..90fbdadf7ab --- /dev/null +++ b/apps/sim/tools/sportmonks_odds/types.ts @@ -0,0 +1,230 @@ +import type { OutputProperty } from '@/tools/types' + +/** + * Base URL for the Sportmonks Odds API v3. + * @see https://docs.sportmonks.com/v3/odds-api/getting-started/welcome + */ +export const SPORTMONKS_ODDS_BASE_URL = 'https://api.sportmonks.com/v3/odds' + +/** + * Output property definitions for a pre-match Odd object. + * @see https://docs.sportmonks.com/v3/odds-api/getting-started/entities/odd + */ +export const SPORTMONKS_ODD_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the odd' }, + fixture_id: { type: 'number', description: 'Fixture the odd belongs to' }, + market_id: { type: 'number', description: 'Market the odd belongs to' }, + bookmaker_id: { type: 'number', description: 'Bookmaker offering the odd' }, + label: { type: 'string', description: 'Outcome label (e.g. 1, X, 2)', nullable: true }, + value: { type: 'string', description: 'Decimal odds value', nullable: true }, + name: { type: 'string', description: 'Outcome name (e.g. Home, Draw, Away)', nullable: true }, + sort_order: { + type: 'number', + description: 'Sort order of the odd', + nullable: true, + optional: true, + }, + market_description: { + type: 'string', + description: 'Description of the market', + nullable: true, + optional: true, + }, + probability: { + type: 'string', + description: 'Implied probability (e.g. 48.78%)', + nullable: true, + optional: true, + }, + dp3: { + type: 'string', + description: 'Decimal odds to 3 decimal places', + nullable: true, + optional: true, + }, + fractional: { + type: 'string', + description: 'Fractional odds (e.g. 31/15)', + nullable: true, + optional: true, + }, + american: { + type: 'string', + description: 'American/moneyline odds (e.g. +104)', + nullable: true, + optional: true, + }, + winning: { + type: 'boolean', + description: 'Whether this is the winning outcome', + nullable: true, + optional: true, + }, + stopped: { + type: 'boolean', + description: 'Whether the odd is stopped', + nullable: true, + optional: true, + }, + total: { + type: 'string', + description: 'Total line for over/under markets', + nullable: true, + optional: true, + }, + handicap: { + type: 'string', + description: 'Handicap line for handicap markets', + nullable: true, + optional: true, + }, + participants: { + type: 'string', + description: 'Participant ids related to the outcome', + nullable: true, + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for an in-play Odd object. + * @see https://docs.sportmonks.com/v3/odds-api/getting-started/entities/inplayodd + */ +export const SPORTMONKS_INPLAY_ODD_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the odd' }, + fixture_id: { type: 'number', description: 'Fixture the odd belongs to' }, + external_id: { + type: 'number', + description: 'External id of the odd', + nullable: true, + optional: true, + }, + market_id: { type: 'number', description: 'Market the odd belongs to' }, + bookmaker_id: { type: 'number', description: 'Bookmaker offering the odd' }, + label: { type: 'string', description: 'Outcome label (e.g. 1, X, 2)', nullable: true }, + value: { type: 'string', description: 'Decimal odds value', nullable: true }, + name: { type: 'string', description: 'Outcome name', nullable: true }, + sort_order: { + type: 'number', + description: 'Sort order of the odd', + nullable: true, + optional: true, + }, + market_description: { + type: 'string', + description: 'Description of the market', + nullable: true, + optional: true, + }, + probability: { + type: 'string', + description: 'Implied probability', + nullable: true, + optional: true, + }, + dp3: { + type: 'string', + description: 'Decimal odds to 3 decimal places', + nullable: true, + optional: true, + }, + fractional: { type: 'string', description: 'Fractional odds', nullable: true, optional: true }, + american: { + type: 'string', + description: 'American/moneyline odds', + nullable: true, + optional: true, + }, + winning: { + type: 'boolean', + description: 'Whether this is the winning outcome', + nullable: true, + optional: true, + }, + suspended: { + type: 'boolean', + description: 'Whether the odd is suspended', + nullable: true, + optional: true, + }, + stopped: { + type: 'boolean', + description: 'Whether the odd is stopped', + nullable: true, + optional: true, + }, + total: { + type: 'string', + description: 'Total line for over/under markets', + nullable: true, + optional: true, + }, + handicap: { + type: 'string', + description: 'Handicap line for handicap markets', + nullable: true, + optional: true, + }, + participants: { + type: 'string', + description: 'Participant ids related to the outcome', + nullable: true, + optional: true, + }, +} as const satisfies Record + +/** + * Output property definitions for a Bookmaker object. + * @see https://docs.sportmonks.com/v3/odds-api/getting-started/entities/bookmaker + */ +export const SPORTMONKS_BOOKMAKER_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the bookmaker' }, + name: { type: 'string', description: 'Name of the bookmaker' }, + logo: { type: 'string', description: 'Logo of the bookmaker', nullable: true, optional: true }, +} as const satisfies Record + +/** + * Output property definitions for a Market object. + * @see https://docs.sportmonks.com/v3/odds-api/getting-started/entities/market + */ +export const SPORTMONKS_MARKET_PROPERTIES = { + id: { type: 'number', description: 'Unique id of the market' }, + name: { type: 'string', description: 'Name of the market' }, +} as const satisfies Record + +export interface SportmonksOdd { + id: number + fixture_id: number + market_id: number + bookmaker_id: number + label: string | null + value: string | null + name: string | null + sort_order?: number | null + market_description?: string | null + probability?: string | null + dp3?: string | null + fractional?: string | null + american?: string | null + winning?: boolean | null + stopped?: boolean | null + total?: string | null + handicap?: string | null + participants?: string | null +} + +export interface SportmonksInplayOdd extends SportmonksOdd { + external_id?: number | null + suspended?: boolean | null +} + +export interface SportmonksBookmaker { + id: number + name: string + logo?: string | null +} + +export interface SportmonksMarket { + id: number + name: string +}