Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
30c368d
fix(google-chat): preserve complete bounded syncs
waleedlatif1 Oct 12, 2026
04fd8f2
feat(library): How Do You Control AI Agent Spend Across Teams? (#8917)
icecrasher321 Oct 12, 2026
2faa065
feat(library): AI Agent Version Control and Rollback: Platforms, Proc…
icecrasher321 Oct 12, 2026
b2570b1
docs(library): update what-is-an-agentic-workflow (#8920)
icecrasher321 Oct 12, 2026
218dce3
feat(library): How Do You Build an AI Agent That Answers Questions Fr…
icecrasher321 Oct 12, 2026
f7b4147
perf(logs): order public log pages on an indexed column and retire a …
waleedlatif1 Oct 12, 2026
3eea45f
perf(mothership): stop re-reading and over-serving chat transcripts (…
waleedlatif1 Oct 12, 2026
f471569
perf(tables): read a sorted view's order once per drain and project s…
waleedlatif1 Oct 12, 2026
6b451c1
feat(library): How Do You Build a HubSpot AI Agent for Lead Enrichmen…
icecrasher321 Oct 12, 2026
ebc2214
feat(library): add Stripe payment automation and alert guide
icecrasher321 Oct 12, 2026
4b76ba1
fix(sandbox): name a failed Function's error from its headline, not a…
waleedlatif1 Oct 12, 2026
0ef4d61
feat(library): add Stripe billing and failed-payment agent guide
icecrasher321 Oct 12, 2026
acbbb1d
fix(files): refuse an unknown file per read instead of blanking the C…
waleedlatif1 Oct 12, 2026
d140111
feat(changelog): publish the Nebius model update (#8924)
waleedlatif1 Oct 12, 2026
6511d43
docs(changelog): record completed editorial and media review (#8927)
waleedlatif1 Oct 12, 2026
0951bde
fix(google-chat): avoid unchanged transcript reindexing (#8925)
waleedlatif1 Oct 12, 2026
12a321a
fix(chat): name stackless error subclasses and compact stored unfinis…
waleedlatif1 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/docs/content/docs/workflows/blocks/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ For a custom cloud deployment, enter its provider prefix and model ID: `azure/my

Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing.

### Nebius Token Factory

Select a model beginning with `nebius/` to run an open model through Nebius Token Factory. Sim includes DeepSeek, Kimi, Qwen, and other models in the [Nebius catalog](https://www.sim.ai/models/nebius).

Bring your own Nebius Token Factory API key. On Sim Cloud, a workspace admin can add it under **Workspace settings → BYOK → Nebius**. On self-hosted Sim, provide the key in the Agent block's **API Key** field, using a [secret reference](/platform/credentials). Model features such as image input and structured outputs depend on the selected model.

### Jev evaluation models

Select `jev-latest` from TypeSafe in the Agent model selector. Hosted Sim supplies a key and bills model usage through the normal credit system; workspace or organization BYOK keys override the hosted key without model charges. Self-hosted users enter their TypeSafe key in the block. Use `jev-1.13.0` to pin a version or `jev-preview` to follow preview releases. These models use **State** and **Questions** in place of conversational messages. State accepts text or a reference to a JSON object or array. Questions is a JSON object keyed by the answer names you want:
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/copilot/chat/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type LiveTurnSnapshot,
readLiveTurnSnapshot,
} from '@/lib/mothership/chat/live-turn-snapshot'
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import type { MothershipEffort } from '@/lib/mothership/model-options'
import {
authenticateCopilotRequestSessionOnly,
Expand Down Expand Up @@ -112,7 +112,7 @@ export async function GET(req: NextRequest) {
messages: normalizedMessages,
activeStreamId: chat.conversationId || null,
...(streamSnapshot ? { streamSnapshot } : {}),
})
}).map(compactToolCallParams)

logger.info(`Retrieved chat ${chatId}`)
return NextResponse.json({
Expand Down
107 changes: 107 additions & 0 deletions apps/sim/app/api/mothership/chats/[chatId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,113 @@ describe('GET /api/mothership/chats/[chatId]', () => {
}
)

it('serves settled tool-call params bounded without mutating the loaded row', async () => {
const storedParams = { path: 'workspace/notes.md', stdout: 'x'.repeat(50_000) }
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-heavy',
type: 'mothership',
title: 'Heavy',
messages: [
{
id: 'assistant-heavy',
role: 'assistant',
content: '',
timestamp: '2026-10-01T12:00:00Z',
contentBlocks: [
{
type: 'tool',
phase: 'call',
toolCall: {
id: 'call-read',
name: 'read',
state: 'success',
params: storedParams,
result: { success: true },
},
},
],
},
],
resources: [],
conversationId: null,
createdAt: new Date('2026-10-01T12:00:00Z'),
updatedAt: new Date('2026-10-01T12:00:00Z'),
})

const response = await GET(
createRequest('chat-heavy'),
createRouteContext({ chatId: 'chat-heavy' })
)
const body = await response.json()
const params = body.chat.messages[0].contentBlocks[0].toolCall.params

expect(params.path).toBe('workspace/notes.md')
expect(params.stdout.length).toBeLessThanOrEqual(1024)
expect(storedParams.stdout).toHaveLength(50_000)
})

it('bounds settled tool-call params of the in-flight turn and keeps a running call whole', async () => {
const stdout = 'x'.repeat(50_000)
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-live',
type: 'mothership',
title: 'Live',
messages: [
{
id: 'stream-live',
role: 'user',
content: 'Run it',
timestamp: '2026-10-01T12:00:00Z',
},
],
resources: [],
conversationId: 'stream-live',
createdAt: new Date('2026-10-01T12:00:00Z'),
updatedAt: new Date('2026-10-01T12:00:00Z'),
})
const toolEvent = (seq: number, payload: Record<string, unknown>) => ({
v: 1,
type: 'tool',
seq,
ts: '2026-10-01T12:00:01Z',
stream: { streamId: 'stream-live' },
payload,
})
mockReadEvents.mockResolvedValueOnce([
toolEvent(1, {
phase: 'call',
toolCallId: 'tc-done',
toolName: 'read',
arguments: { path: 'workspace/done.md', stdout },
}),
toolEvent(2, { phase: 'result', toolCallId: 'tc-done', toolName: 'read', success: true }),
toolEvent(3, {
phase: 'call',
toolCallId: 'tc-running',
toolName: 'read',
arguments: { path: 'workspace/running.md', stdout },
}),
])

const response = await GET(
createRequest('chat-live'),
createRouteContext({ chatId: 'chat-live' })
)
const body = await response.json()
const toolParams = Object.fromEntries(
body.chat.messages[1].contentBlocks
.filter((block: { toolCall?: unknown }) => block.toolCall)
.map((block: { toolCall: { id: string; params: Record<string, string> } }) => [
block.toolCall.id,
block.toolCall.params,
])
)

expect(toolParams['tc-done'].path).toBe('workspace/done.md')
expect(toolParams['tc-done'].stdout.length).toBeLessThanOrEqual(1024)
expect(toolParams['tc-running'].stdout).toHaveLength(50_000)
})

it('clears activeStreamId when the redis lock has expired (stuck-yellow bug)', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-stuck',
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/mothership/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type LiveTurnSnapshot,
readLiveTurnSnapshot,
} from '@/lib/mothership/chat/live-turn-snapshot'
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness'
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
import {
Expand Down Expand Up @@ -83,7 +83,7 @@ export const GET = withRouteHandler(
messages: normalizedMessages,
activeStreamId: liveStreamId,
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
})
}).map(compactToolCallParams)
Comment thread
waleedlatif1 marked this conversation as resolved.

return NextResponse.json({
success: true,
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/app/api/mothership/chats/[chatId]/stream-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getMothershipChatStreamStatusContract } from '@/lib/api/contracts/mothership-chats'
import {
defineInternalJsonRoute,
internalJsonPresenters,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readChatStreamStatus } from '@/lib/mothership/chat/application/read-stream-status'

/** Whether the owner's chat is still streaming, read without its transcript. */
export const GET = defineInternalJsonRoute({
contract: getMothershipChatStreamStatusContract,
operation: readChatStreamStatus.operation,
auth: internalSessionAuth,
rateLimit: internalRateLimits.none({
reason: 'Replaces full chat detail reads, which have no separate rate bucket.',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ chatId: params.chatId }),
useCase: readChatStreamStatus,
present: internalJsonPresenters.withSuccess,
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resourceScopeKey } from '@/lib/core/resource-scope'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
Expand Down Expand Up @@ -26,8 +27,12 @@ vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event'
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
import {
makeStreamLoopDeps,
ref,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { useTableViewPinStore } from '@/stores/table/view-pin/store'

type ResourceEvent = Extract<PersistedStreamEventEnvelope, { type: 'resource' }>
Expand Down Expand Up @@ -109,6 +114,89 @@ function tableUpsertEvent(id: string, viewId?: string): PersistedStreamEventEnve
} as PersistedStreamEventEnvelope
}

describe('live resource effects', () => {
const detailKey = mothershipChatKeys.detail('chat-1')
const kept: MothershipResource = { type: 'file', id: 'kept', title: 'kept.md' }
const dropped: MothershipResource = { type: 'file', id: 'dropped', title: 'dropped.md' }
const created: MothershipResource = { type: 'table', id: 'tbl-new', title: 'Leads' }

/** A server whose stored resources the effects below have already changed. */
function serverChat() {
let stored = [kept, dropped]
const reads = vi.fn(
async (): Promise<MothershipChatHistory> => ({
id: 'chat-1',
title: null,
messages: [],
activeStreamId: 'stream-1',
resources: stored,
})
)
return { reads, persistEffects: () => (stored = [kept, created]) }
}

function effect(op: 'upsert' | 'remove', resource: MothershipResource): ResourceEvent {
return {
...removeEvent('file', resource.id),
payload: { op, effectId: `effect:${resource.id}`, resource },
} as ResourceEvent
}

function applyEffects(queryClient: QueryClient) {
const deps = makeStreamLoopDeps({ queryClient, chatIdRef: ref<string | undefined>('chat-1') })
handleResourceEvent({ deps } as StreamLoopContext, effect('upsert', created))
handleResourceEvent({ deps } as StreamLoopContext, effect('remove', dropped))
}

it('records effects in the cached chat without reloading its transcript', async () => {
const queryClient = new QueryClient()
const server = serverChat()
queryClient.setQueryData(detailKey, await server.reads())
server.reads.mockClear()
const unsubscribe = new QueryObserver(queryClient, {
queryKey: detailKey,
queryFn: server.reads,
staleTime: Number.POSITIVE_INFINITY,
}).subscribe(() => {})

server.persistEffects()
applyEffects(queryClient)
await queryClient.refetchQueries({ queryKey: detailKey, stale: true })

expect(queryClient.getQueryData<MothershipChatHistory>(detailKey)?.resources).toEqual([
kept,
created,
])
expect(server.reads).not.toHaveBeenCalled()
unsubscribe()
})

it.each([
{ cached: true, label: 'a cached chat' },
{ cached: false, label: 'a chat loading for the first time' },
])(
'restarts a transcript read of $label that began before the effects were stored',
async ({ cached }) => {
const queryClient = new QueryClient()
const server = serverChat()
if (cached) queryClient.setQueryData(detailKey, await server.reads())
const staleRead = queryClient.fetchQuery({ queryKey: detailKey, queryFn: server.reads })

server.persistEffects()
applyEffects(queryClient)
await staleRead.catch(() => undefined)

await vi.waitFor(() => {
expect(queryClient.isFetching({ queryKey: detailKey })).toBe(0)
expect(queryClient.getQueryData<MothershipChatHistory>(detailKey)?.resources).toEqual([
kept,
created,
])
})
}
)
})

describe('handleResourceEvent saved-view pins', () => {
beforeEach(() => {
useTableViewPinStore.getState().reset()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache'
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
Expand All @@ -34,6 +34,35 @@ type ResourceEvent = Extract<
{ type: typeof MothershipStreamV1EventType.resource }
>

/**
* Applies a resource effect the server already wrote into the chat's stored
* resources to both the tab strip and the cached chat detail. Mirroring it
* keeps a later hydration from restoring the pre-effect list without reloading
* the whole transcript; only a detail read already in flight, which may predate
* the effect, is restarted.
*/
function applyResourceEffect(
ctx: StreamLoopContext,
update: (resources: MothershipResource[]) => MothershipResource[]
): void {
const { queryClient, setResources, chatIdRef } = ctx.deps
setResources(update)
const chatId = chatIdRef.current
if (!chatId) return
const queryKey = mothershipChatKeys.detail(chatId)
queryClient.setQueryData<MothershipChatHistory>(
queryKey,
(current) => current && { ...current, resources: update(current.resources) }
)
if (queryClient.isFetching({ queryKey, exact: true }) > 0) {
// A read with no cached data yet would dedupe a refetch onto itself, so it
// is cancelled first; `revert: false` keeps the effect written above.
void queryClient
.cancelQueries({ queryKey, exact: true }, { revert: false })
.then(() => queryClient.refetchQueries({ queryKey, exact: true }))
}
}

/**
* Applies a streamed resource upsert/remove to the mothership resource list,
* reconciling it with any in-flight or just-completed file-preview handoff so a
Expand Down Expand Up @@ -160,19 +189,19 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
notifyWorkflowExternalUpdate(payload.resource.id)
}
if (payload.op === 'refresh') return
if (payload.effectId || payload.replay || ctx.deps.options.deferFlushes) {
if (payload.replay || ctx.deps.options.deferFlushes) {
/** Recovery refetches current panel state instead of repeating historical focus commands. */
const chatId = ctx.deps.chatIdRef.current
if (chatId) void queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
if (payload.replay || ctx.deps.options.deferFlushes) return
return
}
if (payload.op === 'clear_view') {
const pinStore = useTableViewPinStore.getState()
const pendingPin = pinStore.pins[payload.resource.id]
if (pendingPin?.viewId === payload.resource.viewId) {
pinStore.consume(payload.resource.id, pendingPin.seq)
}
setResources((current) =>
const unpin = (current: MothershipResource[]) =>
current.map((resource) => {
if (
(resource.workspaceId ?? chatWorkspaceId) !== workspaceId ||
Expand All @@ -184,7 +213,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
const { viewId: _view, ...unpinned } = resource
return unpinned
})
)
if (payload.effectId) applyResourceEffect(ctx, unpin)
else setResources(unpin)
return
}
// A saved view the agent just created or edited: the table opens on it, and
Expand All @@ -208,7 +238,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
if (payload.op === MothershipStreamV1ResourceOp.remove) {
const resourceType = resource.type
if (payload.effectId) {
setResources((current) =>
applyResourceEffect(ctx, (current) =>
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
)
ctx.deps.setActiveResourceId((current) =>
Expand Down Expand Up @@ -263,7 +293,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
? !resourcesRef.current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
: addResource(resourceUpdate)
if (payload.effectId) {
setResources((current) => {
applyResourceEffect(ctx, (current) => {
const previous = current.find(
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): Str
invalidateQueries: vi.fn(),
cancelQueries: vi.fn(),
setQueryData: vi.fn(),
isFetching: vi.fn(() => 0),
// double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures
} as unknown as QueryClient,
assistantId: 'assistant-1',
Expand Down
Loading
Loading