feat(mship): add parallel subagents, improve streaming performance (#5122)

* feat(subagents): add support for parallel subagents

* fix(subagents): address parallel-subagent bugs

* progress on streaming refactor

* improvement(subagents): update comment to reflect new go feature flag

* debug mode progress

* remove debug logs

* fix(validation): add escape annotation

* improvement(code): remove dead fallbacks

* fix subagent lane fallback issue

* fix(mothership): increase default redis event limit to 100k from 5k

* fix(mothership): streaming invariant projection enforcement

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Siddharth Ganesan
2026-06-18 11:27:49 -07:00
committed by GitHub
co-authored by Vikhyath Mondreti
parent 597d7eafb5
commit e5f3965ed1
127 changed files with 12200 additions and 1124 deletions
+8
View File
@@ -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
+18
View File
@@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SportmonksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 25 24'
fill='none'
fillRule='evenodd'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M11.857 8.546c1.893 0 3.517.678 4.872 2.033 1.355 1.336 2.032 2.96 2.032 4.872 0 1.91-.677 3.535-2.032 4.871-1.355 1.355-2.979 2.032-4.872 2.032H1V17.093h10.857c.446 0 .825-.157 1.142-.473.334-.334.5-.724.5-1.17 0-.445-.166-.835-.5-1.17a1.558 1.558 0 00-1.142-.472H7.905c-1.912 0-3.537-.677-4.873-2.032C1.678 10.421 1 8.796 1 6.903 1 4.993 1.678 3.368 3.033 2.032 4.368.678 5.992 0 7.905 0h10.188V5.263H7.904a1.65 1.65 0 00-1.17.473 1.586 1.586 0 00-.472 1.169c0 .445.157.835.473 1.17.334.315.724.472 1.17.472h3.952z'
fill='currentColor'
/>
<circle cx='21.27' cy='20.123' r='2.732' fill='#FF0F50' />
</svg>
)
}
export function SquareIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 501.42 501.42' xmlns='http://www.w3.org/2000/svg'>
+2
View File
@@ -195,6 +195,7 @@ import {
SixtyfourIcon,
SlackIcon,
SmtpIcon,
SportmonksIcon,
SQSIcon,
SquareIcon,
SshIcon,
@@ -449,6 +450,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
sixtyfour: SixtyfourIcon,
slack: SlackIcon,
smtp: SmtpIcon,
sportmonks: SportmonksIcon,
sqs: SQSIcon,
square: SquareIcon,
ssh: SshIcon,
@@ -196,6 +196,7 @@
"sixtyfour",
"slack",
"smtp",
"sportmonks",
"sqs",
"square",
"ssh",
File diff suppressed because it is too large Load Diff
@@ -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)
})
})
@@ -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<AgentGroupItem, { type: 'tool' }> => 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<boolean | null>(null)
const expanded = manualExpanded ?? autoExpanded
@@ -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'
@@ -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 <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
}
if (status === 'cancelled') {
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
}
if (status === 'interrupted') {
if (display === 'interrupted') {
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
}
const Icon = getToolIcon(toolName)
@@ -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.
@@ -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'
@@ -2,3 +2,4 @@ export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
@@ -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'),
@@ -94,6 +111,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'),
@@ -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,11 +18,14 @@ import {
PendingTagIndicator,
ThinkingBlock,
} from './components'
import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
const FILE_SUBAGENT_ID = 'file'
interface TextSegment {
type: 'text'
/** Stable per-run React key (see the counters in parseBlocksWithSpanTree). */
id: string
content: string
}
@@ -93,17 +96,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<ContentBlock['toolCall']>): boolean {
return tc.status === 'executing'
}
@@ -155,10 +147,10 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): 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: [],
@@ -187,6 +179,27 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsBySpanId = new Map<string, AgentGroupSegment>()
// 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<string, string>()
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]
@@ -202,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
}
@@ -226,6 +239,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
}
@@ -236,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
@@ -252,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 })
}
}
@@ -266,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
@@ -297,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)
@@ -314,7 +329,17 @@ 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;
// 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
@@ -340,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
@@ -444,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: [],
@@ -534,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
}
@@ -656,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
}
@@ -678,6 +707,7 @@ interface MessageContentProps {
fallbackContent: string
isStreaming: boolean
onOptionSelect?: (id: string) => void
onPhaseChange?: (phase: MessagePhase) => void
}
function MessageContentInner({
@@ -685,17 +715,34 @@ 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
: fallbackContent?.trim()
? [{ type: 'text' as const, content: fallbackContent }]
? [{ type: 'text' as const, id: 'text-fallback', 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 (
@@ -707,21 +754,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 (
<div className='space-y-[10px]'>
@@ -730,7 +772,7 @@ function MessageContentInner({
case 'text':
return (
<ChatContent
key={`text-${i}`}
key={segment.id}
content={segment.content}
isStreaming={shouldSmoothTextSegment({
isStreaming,
@@ -739,6 +781,9 @@ function MessageContentInner({
})}
onOptionSelect={onOptionSelect}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
}
/>
)
case 'thinking': {
@@ -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')
})
})
@@ -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<SVGProps<SVGSVGElement>>
@@ -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'
)
}
@@ -1,6 +1,15 @@
'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import {
memo,
useCallback,
useDeferredValue,
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 +18,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 +165,7 @@ interface AssistantMessageRowProps {
precedingUserContent?: string
rowClassName: string
onOptionSelect?: (id: string) => void
onAnimatingChange?: (animating: boolean) => void
}
const AssistantMessageRow = memo(function AssistantMessageRow({
@@ -163,11 +174,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<MessagePhase>(isStreaming ? 'streaming' : 'settled')
const onAnimatingChangeRef = useRef(onAnimatingChange)
onAnimatingChangeRef.current = onAnimatingChange
useEffect(() => {
onAnimatingChangeRef.current?.(phase !== 'settled')
}, [phase])
if (!hasAnyBlocks && !trimmedContent && isStreaming) {
return <PendingTagIndicator />
}
@@ -177,7 +197,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
return null
}
const showActions = !isStreaming && (message.content || hasAnyBlocks)
const showActions = phase === 'settled' && (message.content || hasAnyBlocks)
return (
<div className={rowClassName}>
@@ -186,6 +206,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
fallbackContent={message.content}
isStreaming={isStreaming}
onOptionSelect={onOptionSelect}
onPhaseChange={setPhase}
/>
{showActions && (
<div className='mt-2.5'>
@@ -202,7 +223,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
})
export function MothershipChat({
messages,
messages: messagesProp,
isSending,
isReconnecting = false,
isLoading = false,
@@ -229,8 +250,16 @@ 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)
const [lastRowAnimating, setLastRowAnimating] = useState(false)
const scrollElementRef = useRef<HTMLDivElement | null>(null)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
const setScrollElement = useCallback(
(el: HTMLDivElement | null) => {
scrollElementRef.current = el
@@ -282,6 +311,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 +439,7 @@ export function MothershipChat({
precedingUserContent={precedingUserContentByIndex[index]}
rowClassName={cn(styles.assistantRow, styles.rowGap)}
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
onAnimatingChange={isLast ? setLastRowAnimating : undefined}
/>
)}
</div>
@@ -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', () => {
@@ -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)
@@ -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<PersistedStreamEventEnvelope, { type: 'complete' }>
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()
}
@@ -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<PersistedStreamEventEnvelope, { type: 'error' }>
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()
}
@@ -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<PersistedStreamEventEnvelope, { type: 'run' }>
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()
}
@@ -14,13 +14,19 @@ import {
type SpanEvent = Extract<PersistedStreamEventEnvelope, { type: 'span' }>
/**
* 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,44 +81,6 @@ export function handleSpanEvent(
deps.setActiveResourceId(lastFileResource.id)
}
}
if (
!parentToolCallId ||
parentToolCallId === state.activeSubagentParentToolCallId ||
name === state.activeSubagent
) {
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()
}
}
@@ -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<PersistedStreamEventEnvelope, { type: 'text' }>
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()
}
@@ -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'
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'
const SCOPE: StreamEventScope = {
scopedSubagent: undefined,
scopedParentToolCallId: undefined,
scopedAgentId: undefined,
scopedSpanId: undefined,
scopedParentSpanId: undefined,
spanIdentity: {},
}
type ToolEvent = Parameters<typeof handleToolEvent>[1]
function toolCall(id: string, name = 'my_tool'): ToolEvent {
let seq = 0
function toolEnv(payload: Record<string, unknown>): 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 {
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
}
const toolCall = (id: string, name = 'my_tool') =>
toolEnv({ phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name })
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')
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',
})
it('buffers a result that arrives before its call, then applies it when the call lands', () => {
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)
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' } },
})
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)
const filePreviewComplete = (id: string) =>
toolEnv({ previewPhase: 'file_preview_complete', toolCallId: id, toolName: 'workspace_file' })
function streamingSession(toolCallId: string): FilePreviewSession {
return {
schemaVersion: 1,
id: toolCallId,
streamId: 's',
toolCallId,
status: 'streaming',
fileName: 'doc.md',
previewText: 'hello',
previewVersion: 1,
updatedAt: '',
}
}
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', () => {
const ctx = createStreamLoopContext(makeStreamLoopDeps())
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<Record<string, FilePreviewSession>>({})
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')
})
})
@@ -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<PersistedStreamEventEnvelope, { type: 'tool' }>
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
/** 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 (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()
/**
* 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 (tc.name === ReadTool.id && tc.status === 'success') {
const readArgs = state.toolArgsMap.get(id)
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined
if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) {
const out = output as Record<string, unknown> | undefined
const editData =
editOutput && typeof editOutput.data === 'object' && editOutput.data !== null
? (editOutput.data as Record<string, unknown>)
out && typeof out.data === 'object' && out.data !== null
? (out.data as Record<string, unknown>)
: 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<string, unknown> | 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
if (isWorkflowToolName(name) && !isPartial) {
const shouldStartWorkflowTool =
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
node?.kind === 'tool' &&
node.status === 'running' &&
!node.result
if (shouldStartWorkflowTool) {
const args = payload.arguments as Record<string, unknown> | undefined
deps.startClientWorkflowTool(rawId, name, args ?? {})
}
}
ops.flush()
if (isWorkflowToolName(name) && !isPartial) {
const shouldStartWorkflowTool =
!deps.options.suppressedWorkflowToolStartIds?.has(id) &&
(isNewToolCall ||
(existingToolCall?.status === ToolCallStatus.executing && !existingToolCall.result))
if (shouldStartWorkflowTool) {
deps.startClientWorkflowTool(id, name, args ?? {})
}
}
}
@@ -9,3 +9,4 @@ export {
type StreamLoopState,
} from './stream-context'
export { finalizeResidualToolCalls } from './stream-helpers'
export { applyTurnTerminal } from './turn-model'
@@ -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<ContentBlock[]>(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<string | undefined>(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,61 +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('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId, then active', () => {
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')
expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBe('activeAgent')
})
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')
})
})
})
@@ -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<string, number>
toolArgsMap: Map<string, Record<string, unknown>>
subagentByParentToolCallId: Map<string, string>
subagentBySpanId: Map<string, string>
pendingToolResults: Map<string, ToolResultPhasePayload>
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<string, number>(),
toolArgsMap: new Map<string, Record<string, unknown>>(),
subagentByParentToolCallId: new Map<string, string>(),
subagentBySpanId: new Map<string, string>(),
pendingToolResults: new Map<string, ToolResultPhasePayload>(),
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,140 +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)
}
}
for (const block of state.blocks) {
if (block.type === 'subagent' && block.spanId && block.content) {
state.subagentBySpanId.set(block.spanId, block.content)
}
}
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
}
}
} 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) => {
if (block && 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 => {
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 state.activeSubagent
}
const resolveParentForSubagentBlock = (
subagent: string | undefined,
scopedParent: string | undefined
): string | undefined => {
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
}
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<ChatMessage> = {
content: state.runningText,
contentBlocks: [...state.blocks],
content: modelContent,
contentBlocks: modelBlocks,
}
if (state.streamRequestId) snapshot.requestId = state.streamRequestId
deps.setPendingMessages((prev) => {
@@ -364,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) => {
@@ -404,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 `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
const ops: StreamLoopOps = {
isStale,
toEventMs,
stampBlockEnd,
ensureTextBlock,
ensureThinkingBlock,
resolveScopedSubagent,
resolveParentForSubagentBlock,
appendInlineErrorTag,
buildInlineErrorTag,
flush,
flushText,
}
@@ -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<string> = new Set([
export type StreamPayload = Record<string, unknown>
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
@@ -0,0 +1,408 @@
/**
* @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<string, unknown>, 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<typeof modelToContentBlocks>, 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('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',
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')
})
})
@@ -0,0 +1,337 @@
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<string, unknown>,
scope?: Record<string, unknown>,
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 } : {}),
// 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<string, unknown> | 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
}
@@ -0,0 +1,486 @@
/**
* @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<string, unknown>,
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)
})
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', () => {
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('<mothership-error>')
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('<mothership-error>').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')
})
})
@@ -0,0 +1,656 @@
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<string, unknown>
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<string, LifecycleNode>
/** 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<string, string>
/** `${spanId}::${channel}` -> currently-open text node id (cleared on a lane break). */
openTextByKey: Map<string, string>
/**
* 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<string, string>
/** 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
}
/**
* 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<string, unknown> {
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<string, unknown> {
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<NodeStatus>([
'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<TurnStatus, 'streaming'>): NodeStatus {
if (turn === 'cancelled') return 'cancelled'
if (turn === 'error') return 'error'
return 'success'
}
/**
* Builds the inline `<mothership-error>` 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, unknown>): 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 `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
/** 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) 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 } : {}) })
}
/**
* 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).
* `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 = 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
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
}
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) ?? ''
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
// continues in the single "writing" row), reopening it for the edit.
if (toolName === EDIT_CONTENT_TOOL) {
// 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)
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
}
}
// 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),
spanId,
toolName,
seq,
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,
seq,
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:${seq}`)
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: seq,
...(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 = seq
}
}
break
}
case MothershipStreamV1EventType.run: {
const payload = payloadRecord(envelope.payload)
const kind = payload.kind
if (kind === MothershipStreamV1RunKind.compaction_start) {
const node = upsertToolNode(
model,
`compaction:${seq}`,
spanId,
'context_compaction',
seq,
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:${seq}`,
spanId,
'context_compaction',
seq,
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, seq, 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<TurnStatus, 'streaming'>): 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
}
}
}
}
@@ -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,
@@ -2010,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
}
@@ -2025,7 +2024,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 +2913,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 +2963,7 @@ export function useChat(
notifyTurnEnded,
reconcileTerminalPreviewSessions,
setTransportIdle,
upsertChatHistory,
]
)
finalizeRef.current = finalize
+818
View File
@@ -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<string, any> = {}
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
+3
View File
@@ -271,6 +271,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'
@@ -578,6 +579,7 @@ const BLOCK_REGISTRY: Record<string, BlockConfig> = {
sixtyfour: SixtyfourBlock,
slack: SlackBlock,
smtp: SmtpBlock,
sportmonks: SportmonksBlock,
spotify: SpotifyBlock,
sqs: SQSBlock,
square: SquareBlock,
@@ -842,6 +844,7 @@ const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
similarweb: SimilarwebBlockMeta,
sixtyfour: SixtyfourBlockMeta,
slack: SlackBlockMeta,
sportmonks: SportmonksBlockMeta,
spotify: SpotifyBlockMeta,
sqs: SQSBlockMeta,
square: SquareBlockMeta,
+18
View File
@@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SportmonksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 25 24'
fill='none'
fillRule='evenodd'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M11.857 8.546c1.893 0 3.517.678 4.872 2.033 1.355 1.336 2.032 2.96 2.032 4.872 0 1.91-.677 3.535-2.032 4.871-1.355 1.355-2.979 2.032-4.872 2.032H1V17.093h10.857c.446 0 .825-.157 1.142-.473.334-.334.5-.724.5-1.17 0-.445-.166-.835-.5-1.17a1.558 1.558 0 00-1.142-.472H7.905c-1.912 0-3.537-.677-4.873-2.032C1.678 10.421 1 8.796 1 6.903 1 4.993 1.678 3.368 3.033 2.032 4.368.678 5.992 0 7.905 0h10.188V5.263H7.904a1.65 1.65 0 00-1.17.473 1.586 1.586 0 00-.472 1.169c0 .445.157.835.473 1.17.334.315.724.472 1.17.472h3.952z'
fill='currentColor'
/>
<circle cx='21.27' cy='20.123' r='2.732' fill='#FF0F50' />
</svg>
)
}
export function SquareIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 501.42 501.42' xmlns='http://www.w3.org/2000/svg'>
+42 -1
View File
@@ -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<string, number>()
for (const block of blocks) {
const tc = block.type === ContentBlockType.tool_call ? block.toolCall : undefined
if (tc) {
const span = block.spanId ?? MAIN_SPAN
if (tc.name === EDIT_CONTENT_TOOL) {
const parentIndex = workspaceFileIndexBySpan.get(span)
const parent = parentIndex !== undefined ? folded[parentIndex] : undefined
if (parent?.type === ContentBlockType.tool_call && parent.toolCall) {
folded[parentIndex!] = {
...parent,
toolCall: { ...parent.toolCall, status: tc.status, result: tc.result },
}
continue
}
} else if (tc.name === WORKSPACE_FILE_TOOL) {
workspaceFileIndexBySpan.set(span, folded.length)
}
}
folded.push(block)
}
return folded
}
const displayMessageCache = new WeakMap<PersistedMessage, ChatMessage>()
/**
@@ -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)
@@ -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
}
@@ -50,6 +50,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
MothershipStreamV1CheckpointPauseFrame: {
additionalProperties: false,
properties: {
checkpointId: {
type: 'string',
},
parentToolCallId: {
type: 'string',
},
@@ -319,6 +319,7 @@ export interface MothershipStreamV1CheckpointPausePayload {
runId: string
}
export interface MothershipStreamV1CheckpointPauseFrame {
checkpointId?: string
parentToolCallId: string
parentToolName: string
pendingToolIds: string[]
@@ -18,17 +18,15 @@ export function createStreamingContext(overrides?: Partial<StreamingContext>): S
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
currentSubagentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentParentToolCallId: undefined,
subAgentParentStack: [],
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
activeFileIntent: null,
activeFileIntents: new Map(),
trace: new TraceCollector(),
...overrides,
}
@@ -23,9 +23,8 @@ function makeContext(): StreamingContext {
pendingToolPromises: new Map(),
awaitingAsyncContinuation: undefined,
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentParentToolCallId: undefined,
subAgentParentStack: [],
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
@@ -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<string, unknown>
type FileIntent = NonNullable<StreamingContext['activeFileIntent']>
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()
}
}
@@ -94,17 +94,15 @@ function createStreamingContext(): StreamingContext {
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
currentSubagentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentParentToolCallId: undefined,
subAgentParentStack: [],
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
activeFileIntent: null,
activeFileIntents: new Map(),
trace: new TraceCollector(),
}
}
+13 -20
View File
@@ -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]
@@ -456,10 +441,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
}
@@ -91,9 +91,8 @@ describe('sse-handlers tool lifecycle', () => {
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentParentToolCallId: undefined,
subAgentParentStack: [],
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
@@ -461,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',
@@ -521,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(
@@ -572,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',
@@ -603,6 +598,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 } })
@@ -33,17 +33,16 @@ export const subAgentHandlers: Record<string, StreamHandler> = {
[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,
})
@@ -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 = {
@@ -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
+11 -12
View File
@@ -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)
}
+13 -2
View File
@@ -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
+33 -7
View File
@@ -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
}
context.currentSubagentThinkingBlock = null
for (const block of context.subagentThinkingBlocks.values()) {
stampBlockEnd(block)
context.contentBlocks.push(block)
}
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
}
/**
@@ -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 })
})
})
+326 -7
View File
@@ -35,6 +35,8 @@ import type {
ExecutionContext,
OrchestratorOptions,
OrchestratorResult,
ResumeContinuation,
ResumeFrame,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
@@ -231,6 +233,299 @@ export async function runCopilotLifecycle(
}
}
// ---------------------------------------------------------------------------
// Per-subagent checkpoint resume (concurrent fan-out)
// ---------------------------------------------------------------------------
//
// 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
// (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 = ResumeContinuation
function isPerSubagentContinuation(c: AsyncContinuation): boolean {
return !!c.frames && c.frames.length > 0 && c.frames.every((f) => !!f.checkpointId)
}
// 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<string, string> {
return {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
...getMothershipSourceEnvHeaders(),
'X-Client-Version': SIM_AGENT_VERSION,
}
}
// 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,
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 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<void> {
const promises: Promise<unknown>[] = []
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,
}
})
}
// 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<string, unknown>,
leg: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions
): Promise<void> {
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
// 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: ResumeFrame,
context: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions,
baseURL: string,
workspaceId?: string
): Promise<AsyncContinuation | null> {
// 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
// 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 (;;) {
if (isAborted(options, context)) return null
await waitForToolIds(context, toolIds)
const results = collectResultsForToolIds(context, toolIds, checkpointId)
const leg = makeResumeLegContext(context)
await runResumeLegWithRetry(
`${baseURL}/api/tools/resume`,
{
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.
//
// 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,
execContext: ExecutionContext,
options: CopilotLifecycleOptions,
baseURL: string,
workspaceId?: string
): Promise<AsyncContinuation | null> {
const frames = continuation.frames ?? []
logger.info('Driving subagent checkpoint chains concurrently', {
childCount: frames.length,
checkpointIds: frames.map((f) => f.checkpointId),
})
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
}
)
)
)
if (firstError !== undefined) throw firstError
return followOns.find((c): c is AsyncContinuation => !!c) ?? null
} finally {
parentSignal?.removeEventListener('abort', onParentAbort)
}
}
// ---------------------------------------------------------------------------
// Checkpoint loop – the core state machine
// ---------------------------------------------------------------------------
@@ -339,12 +634,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,
@@ -405,9 +695,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
@@ -166,7 +166,7 @@ describe('mothership-stream-outbox', () => {
expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith(
'mothership_stream:stream-1:events',
0,
-5_001
-100_001
)
})
@@ -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 = {
@@ -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<typeof setTimeout> | undefined
try {
return await Promise.race([
+59 -22
View File
@@ -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<T = unknown> = ToolExecutionResult & {
@@ -67,6 +75,41 @@ 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<string, unknown>
}
// 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
@@ -79,22 +122,16 @@ export interface StreamingContext {
contentBlocks: ContentBlock[]
toolCalls: Map<string, ToolCallState>
pendingToolPromises: Map<string, Promise<AsyncCompletionSignal>>
awaitingAsyncContinuation?: {
checkpointId: string
executionId?: string
runId?: string
pendingToolCallIds: string[]
frames?: Array<{
parentToolCallId: string
parentToolName: string
pendingToolIds: string[]
}>
}
awaitingAsyncContinuation?: ResumeContinuation
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<string, ContentBlock>
isInThinkingBlock: boolean
subAgentParentToolCallId?: string
subAgentParentStack: string[]
subAgentContent: Record<string, string>
subAgentToolCalls: Record<string, ToolCallState[]>
openSubagentParents?: Set<string>
@@ -104,14 +141,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<string, unknown>
} | 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<string, ActiveFileIntent>
trace: TraceCollector
subAgentTraceSpans?: Map<string, RequestTraceV1Span>
}
@@ -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
@@ -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,
})
@@ -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
@@ -49,9 +49,15 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
return { success: false, message: 'content is required for edit_content' }
}
// Consume the intent from THIS file subagent's channel (its outer tool_use
// id), not just the latest in the message — otherwise two file agents
// writing concurrently would each grab whichever workspace_file landed last
// and write their content into the wrong file. Falls back to latest-in-
// message when no channel id is present (main-agent / legacy calls).
const intent = await consumeLatestFileIntent(workspaceId, {
chatId: context.chatId,
messageId: context.messageId,
channelId: context.parentToolCallId,
})
if (!intent) {
return {
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
consumeLatestFileIntent,
type PendingFileIntent,
storeFileIntent,
} from './file-intent-store'
// Force the in-memory store path so the test is deterministic and Redis-free.
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null }))
function makeIntent(overrides: Partial<PendingFileIntent>): 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()
})
})
@@ -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) {
@@ -428,6 +428,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
userId: context.userId,
chatId: context.chatId,
messageId: context.messageId,
channelId: context.parentToolCallId,
fileRecord: existingFile,
existingContent: currentBuffer.toString('utf-8'),
contentType: normalized.contentType,
@@ -456,6 +457,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
userId: context.userId,
chatId: context.chatId,
messageId: context.messageId,
channelId: context.parentToolCallId,
fileRecord,
contentType: normalized.contentType,
title: normalized.title,
@@ -601,6 +603,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
userId: context.userId,
chatId: context.chatId,
messageId: context.messageId,
channelId: context.parentToolCallId,
fileRecord,
existingContent,
edit: {
@@ -194,6 +194,7 @@ import {
SixtyfourIcon,
SlackIcon,
SmtpIcon,
SportmonksIcon,
SQSIcon,
SquareIcon,
SshIcon,
@@ -425,6 +426,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
sixtyfour: SixtyfourIcon,
slack: SlackIcon,
smtp: SmtpIcon,
sportmonks: SportmonksIcon,
sqs: SQSIcon,
square: SquareIcon,
ssh: SshIcon,
+218 -3
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-06-17",
"updatedAt": "2026-06-18",
"integrations": [
{
"type": "onepassword",
@@ -14931,6 +14931,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",
@@ -15413,7 +15624,7 @@
"slug": "supabase",
"name": "Supabase",
"description": "Use Supabase database",
"longDescription": "Integrate Supabase into the workflow. Supports database operations (query, insert, update, delete, upsert), full-text search, RPC functions, row counting, vector search, and complete storage management (upload, download, list, move, copy, delete files and buckets).",
"longDescription": "Integrate Supabase into the workflow. Supports database operations (query, insert, update, delete, upsert), full-text search, RPC functions, Edge Function invocation, row counting, vector search, and complete storage management (upload, download, list, move, copy, delete files and buckets).",
"bgColor": "#1C1C1C",
"iconName": "SupabaseIcon",
"docsUrl": "https://docs.sim.ai/integrations/supabase",
@@ -15458,6 +15669,10 @@
"name": "Call RPC Function",
"description": "Call a PostgreSQL function in Supabase"
},
{
"name": "Invoke Edge Function",
"description": "Invoke a Supabase Edge Function over HTTP"
},
{
"name": "Introspect Schema",
"description": "Introspect Supabase database schema to get table structures, columns, and relationships"
@@ -15507,7 +15722,7 @@
"description": "Delete a storage bucket in Supabase"
}
],
"operationCount": 22,
"operationCount": 23,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
+106
View File
@@ -3102,6 +3102,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,
@@ -4199,6 +4255,56 @@ export const tools: Record<string, ToolConfig> = {
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,
+89
View File
@@ -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<string, string> {
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<string, OutputProperty>
/** 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,
}
@@ -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,
},
}
@@ -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,
},
},
}
@@ -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,
},
},
}
@@ -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,
},
}
@@ -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,
},
}
@@ -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,
},
},
}
@@ -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,
},
},
}
@@ -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,
},
}
@@ -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' },
},
},
}
@@ -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,
},
},
}
@@ -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,
},
}
+13
View File
@@ -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'
@@ -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,
},
}
@@ -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,
},
}
+172
View File
@@ -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<string, OutputProperty>
/**
* 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<string, OutputProperty>
/**
* 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<string, OutputProperty>
/**
* 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<string, OutputProperty>
/**
* 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<string, OutputProperty>
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
}
@@ -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,
},
},
}
@@ -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,
},
}
@@ -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,
},
}
@@ -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,
},
}
@@ -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 },
},
},
}
@@ -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,
},
},
}
@@ -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,
},
}
@@ -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 },
},
},
}
@@ -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,
},
},
}
@@ -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 },
},
},
}
@@ -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<SportmonksGetTeamParams, SportmonksGetTeamResponse> =
{
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,
},
},
}
@@ -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 },
},
},
}
@@ -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,
},
}
@@ -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'

Some files were not shown because too many files have changed in this diff Show More