Skip to content

Commit 3f8d13f

Browse files
authored
v0.9.19: perf improvements, library guides, files and sandbox fixes
2 parents 22c3de1 + 12a321a commit 3f8d13f

67 files changed

Lines changed: 35385 additions & 363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/workflows/blocks/agent.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ For a custom cloud deployment, enter its provider prefix and model ID: `azure/my
3131

3232
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.
3333

34+
### Nebius Token Factory
35+
36+
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).
37+
38+
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.
39+
3440
### Jev evaluation models
3541

3642
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:

‎apps/sim/app/api/copilot/chat/queries.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type LiveTurnSnapshot,
1212
readLiveTurnSnapshot,
1313
} from '@/lib/mothership/chat/live-turn-snapshot'
14-
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
14+
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
1515
import type { MothershipEffort } from '@/lib/mothership/model-options'
1616
import {
1717
authenticateCopilotRequestSessionOnly,
@@ -112,7 +112,7 @@ export async function GET(req: NextRequest) {
112112
messages: normalizedMessages,
113113
activeStreamId: chat.conversationId || null,
114114
...(streamSnapshot ? { streamSnapshot } : {}),
115-
})
115+
}).map(compactToolCallParams)
116116

117117
logger.info(`Retrieved chat ${chatId}`)
118118
return NextResponse.json({

‎apps/sim/app/api/mothership/chats/[chatId]/route.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,113 @@ describe('GET /api/mothership/chats/[chatId]', () => {
170170
}
171171
)
172172

173+
it('serves settled tool-call params bounded without mutating the loaded row', async () => {
174+
const storedParams = { path: 'workspace/notes.md', stdout: 'x'.repeat(50_000) }
175+
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
176+
id: 'chat-heavy',
177+
type: 'mothership',
178+
title: 'Heavy',
179+
messages: [
180+
{
181+
id: 'assistant-heavy',
182+
role: 'assistant',
183+
content: '',
184+
timestamp: '2026-10-01T12:00:00Z',
185+
contentBlocks: [
186+
{
187+
type: 'tool',
188+
phase: 'call',
189+
toolCall: {
190+
id: 'call-read',
191+
name: 'read',
192+
state: 'success',
193+
params: storedParams,
194+
result: { success: true },
195+
},
196+
},
197+
],
198+
},
199+
],
200+
resources: [],
201+
conversationId: null,
202+
createdAt: new Date('2026-10-01T12:00:00Z'),
203+
updatedAt: new Date('2026-10-01T12:00:00Z'),
204+
})
205+
206+
const response = await GET(
207+
createRequest('chat-heavy'),
208+
createRouteContext({ chatId: 'chat-heavy' })
209+
)
210+
const body = await response.json()
211+
const params = body.chat.messages[0].contentBlocks[0].toolCall.params
212+
213+
expect(params.path).toBe('workspace/notes.md')
214+
expect(params.stdout.length).toBeLessThanOrEqual(1024)
215+
expect(storedParams.stdout).toHaveLength(50_000)
216+
})
217+
218+
it('bounds settled tool-call params of the in-flight turn and keeps a running call whole', async () => {
219+
const stdout = 'x'.repeat(50_000)
220+
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
221+
id: 'chat-live',
222+
type: 'mothership',
223+
title: 'Live',
224+
messages: [
225+
{
226+
id: 'stream-live',
227+
role: 'user',
228+
content: 'Run it',
229+
timestamp: '2026-10-01T12:00:00Z',
230+
},
231+
],
232+
resources: [],
233+
conversationId: 'stream-live',
234+
createdAt: new Date('2026-10-01T12:00:00Z'),
235+
updatedAt: new Date('2026-10-01T12:00:00Z'),
236+
})
237+
const toolEvent = (seq: number, payload: Record<string, unknown>) => ({
238+
v: 1,
239+
type: 'tool',
240+
seq,
241+
ts: '2026-10-01T12:00:01Z',
242+
stream: { streamId: 'stream-live' },
243+
payload,
244+
})
245+
mockReadEvents.mockResolvedValueOnce([
246+
toolEvent(1, {
247+
phase: 'call',
248+
toolCallId: 'tc-done',
249+
toolName: 'read',
250+
arguments: { path: 'workspace/done.md', stdout },
251+
}),
252+
toolEvent(2, { phase: 'result', toolCallId: 'tc-done', toolName: 'read', success: true }),
253+
toolEvent(3, {
254+
phase: 'call',
255+
toolCallId: 'tc-running',
256+
toolName: 'read',
257+
arguments: { path: 'workspace/running.md', stdout },
258+
}),
259+
])
260+
261+
const response = await GET(
262+
createRequest('chat-live'),
263+
createRouteContext({ chatId: 'chat-live' })
264+
)
265+
const body = await response.json()
266+
const toolParams = Object.fromEntries(
267+
body.chat.messages[1].contentBlocks
268+
.filter((block: { toolCall?: unknown }) => block.toolCall)
269+
.map((block: { toolCall: { id: string; params: Record<string, string> } }) => [
270+
block.toolCall.id,
271+
block.toolCall.params,
272+
])
273+
)
274+
275+
expect(toolParams['tc-done'].path).toBe('workspace/done.md')
276+
expect(toolParams['tc-done'].stdout.length).toBeLessThanOrEqual(1024)
277+
expect(toolParams['tc-running'].stdout).toHaveLength(50_000)
278+
})
279+
173280
it('clears activeStreamId when the redis lock has expired (stuck-yellow bug)', async () => {
174281
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
175282
id: 'chat-stuck',

‎apps/sim/app/api/mothership/chats/[chatId]/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
type LiveTurnSnapshot,
2121
readLiveTurnSnapshot,
2222
} from '@/lib/mothership/chat/live-turn-snapshot'
23-
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
23+
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
2424
import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness'
2525
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
2626
import {
@@ -83,7 +83,7 @@ export const GET = withRouteHandler(
8383
messages: normalizedMessages,
8484
activeStreamId: liveStreamId,
8585
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
86-
})
86+
}).map(compactToolCallParams)
8787

8888
return NextResponse.json({
8989
success: true,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { getMothershipChatStreamStatusContract } from '@/lib/api/contracts/mothership-chats'
2+
import {
3+
defineInternalJsonRoute,
4+
internalJsonPresenters,
5+
internalOrchestrationErrorPolicy,
6+
internalRateLimits,
7+
internalSessionAuth,
8+
} from '@/lib/api/server/routes'
9+
import { readChatStreamStatus } from '@/lib/mothership/chat/application/read-stream-status'
10+
11+
/** Whether the owner's chat is still streaming, read without its transcript. */
12+
export const GET = defineInternalJsonRoute({
13+
contract: getMothershipChatStreamStatusContract,
14+
operation: readChatStreamStatus.operation,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.none({
17+
reason: 'Replaces full chat detail reads, which have no separate rate bucket.',
18+
}),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
mapInput: ({ params }) => ({ chatId: params.chatId }),
21+
useCase: readChatStreamStatus,
22+
present: internalJsonPresenters.withSuccess,
23+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { QueryClient, QueryObserver } from '@tanstack/react-query'
12
import { beforeEach, describe, expect, it, vi } from 'vitest'
23
import { resourceScopeKey } from '@/lib/core/resource-scope'
34
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
@@ -26,8 +27,12 @@ vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
2627
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
2728
import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event'
2829
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
29-
import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
30+
import {
31+
makeStreamLoopDeps,
32+
ref,
33+
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
3034
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
35+
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
3136
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
3237

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

117+
describe('live resource effects', () => {
118+
const detailKey = mothershipChatKeys.detail('chat-1')
119+
const kept: MothershipResource = { type: 'file', id: 'kept', title: 'kept.md' }
120+
const dropped: MothershipResource = { type: 'file', id: 'dropped', title: 'dropped.md' }
121+
const created: MothershipResource = { type: 'table', id: 'tbl-new', title: 'Leads' }
122+
123+
/** A server whose stored resources the effects below have already changed. */
124+
function serverChat() {
125+
let stored = [kept, dropped]
126+
const reads = vi.fn(
127+
async (): Promise<MothershipChatHistory> => ({
128+
id: 'chat-1',
129+
title: null,
130+
messages: [],
131+
activeStreamId: 'stream-1',
132+
resources: stored,
133+
})
134+
)
135+
return { reads, persistEffects: () => (stored = [kept, created]) }
136+
}
137+
138+
function effect(op: 'upsert' | 'remove', resource: MothershipResource): ResourceEvent {
139+
return {
140+
...removeEvent('file', resource.id),
141+
payload: { op, effectId: `effect:${resource.id}`, resource },
142+
} as ResourceEvent
143+
}
144+
145+
function applyEffects(queryClient: QueryClient) {
146+
const deps = makeStreamLoopDeps({ queryClient, chatIdRef: ref<string | undefined>('chat-1') })
147+
handleResourceEvent({ deps } as StreamLoopContext, effect('upsert', created))
148+
handleResourceEvent({ deps } as StreamLoopContext, effect('remove', dropped))
149+
}
150+
151+
it('records effects in the cached chat without reloading its transcript', async () => {
152+
const queryClient = new QueryClient()
153+
const server = serverChat()
154+
queryClient.setQueryData(detailKey, await server.reads())
155+
server.reads.mockClear()
156+
const unsubscribe = new QueryObserver(queryClient, {
157+
queryKey: detailKey,
158+
queryFn: server.reads,
159+
staleTime: Number.POSITIVE_INFINITY,
160+
}).subscribe(() => {})
161+
162+
server.persistEffects()
163+
applyEffects(queryClient)
164+
await queryClient.refetchQueries({ queryKey: detailKey, stale: true })
165+
166+
expect(queryClient.getQueryData<MothershipChatHistory>(detailKey)?.resources).toEqual([
167+
kept,
168+
created,
169+
])
170+
expect(server.reads).not.toHaveBeenCalled()
171+
unsubscribe()
172+
})
173+
174+
it.each([
175+
{ cached: true, label: 'a cached chat' },
176+
{ cached: false, label: 'a chat loading for the first time' },
177+
])(
178+
'restarts a transcript read of $label that began before the effects were stored',
179+
async ({ cached }) => {
180+
const queryClient = new QueryClient()
181+
const server = serverChat()
182+
if (cached) queryClient.setQueryData(detailKey, await server.reads())
183+
const staleRead = queryClient.fetchQuery({ queryKey: detailKey, queryFn: server.reads })
184+
185+
server.persistEffects()
186+
applyEffects(queryClient)
187+
await staleRead.catch(() => undefined)
188+
189+
await vi.waitFor(() => {
190+
expect(queryClient.isFetching({ queryKey: detailKey })).toBe(0)
191+
expect(queryClient.getQueryData<MothershipChatHistory>(detailKey)?.resources).toEqual([
192+
kept,
193+
created,
194+
])
195+
})
196+
}
197+
)
198+
})
199+
112200
describe('handleResourceEvent saved-view pins', () => {
113201
beforeEach(() => {
114202
useTableViewPinStore.getState().reset()

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type {
2424
MothershipResource,
2525
MothershipResourceType,
2626
} from '@/app/workspace/[workspaceId]/home/types'
27-
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
27+
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
2828
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
2929
import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache'
3030
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
@@ -34,6 +34,35 @@ type ResourceEvent = Extract<
3434
{ type: typeof MothershipStreamV1EventType.resource }
3535
>
3636

37+
/**
38+
* Applies a resource effect the server already wrote into the chat's stored
39+
* resources to both the tab strip and the cached chat detail. Mirroring it
40+
* keeps a later hydration from restoring the pre-effect list without reloading
41+
* the whole transcript; only a detail read already in flight, which may predate
42+
* the effect, is restarted.
43+
*/
44+
function applyResourceEffect(
45+
ctx: StreamLoopContext,
46+
update: (resources: MothershipResource[]) => MothershipResource[]
47+
): void {
48+
const { queryClient, setResources, chatIdRef } = ctx.deps
49+
setResources(update)
50+
const chatId = chatIdRef.current
51+
if (!chatId) return
52+
const queryKey = mothershipChatKeys.detail(chatId)
53+
queryClient.setQueryData<MothershipChatHistory>(
54+
queryKey,
55+
(current) => current && { ...current, resources: update(current.resources) }
56+
)
57+
if (queryClient.isFetching({ queryKey, exact: true }) > 0) {
58+
// A read with no cached data yet would dedupe a refetch onto itself, so it
59+
// is cancelled first; `revert: false` keeps the effect written above.
60+
void queryClient
61+
.cancelQueries({ queryKey, exact: true }, { revert: false })
62+
.then(() => queryClient.refetchQueries({ queryKey, exact: true }))
63+
}
64+
}
65+
3766
/**
3867
* Applies a streamed resource upsert/remove to the mothership resource list,
3968
* reconciling it with any in-flight or just-completed file-preview handoff so a
@@ -160,19 +189,19 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
160189
notifyWorkflowExternalUpdate(payload.resource.id)
161190
}
162191
if (payload.op === 'refresh') return
163-
if (payload.effectId || payload.replay || ctx.deps.options.deferFlushes) {
192+
if (payload.replay || ctx.deps.options.deferFlushes) {
164193
/** Recovery refetches current panel state instead of repeating historical focus commands. */
165194
const chatId = ctx.deps.chatIdRef.current
166195
if (chatId) void queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
167-
if (payload.replay || ctx.deps.options.deferFlushes) return
196+
return
168197
}
169198
if (payload.op === 'clear_view') {
170199
const pinStore = useTableViewPinStore.getState()
171200
const pendingPin = pinStore.pins[payload.resource.id]
172201
if (pendingPin?.viewId === payload.resource.viewId) {
173202
pinStore.consume(payload.resource.id, pendingPin.seq)
174203
}
175-
setResources((current) =>
204+
const unpin = (current: MothershipResource[]) =>
176205
current.map((resource) => {
177206
if (
178207
(resource.workspaceId ?? chatWorkspaceId) !== workspaceId ||
@@ -184,7 +213,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
184213
const { viewId: _view, ...unpinned } = resource
185214
return unpinned
186215
})
187-
)
216+
if (payload.effectId) applyResourceEffect(ctx, unpin)
217+
else setResources(unpin)
188218
return
189219
}
190220
// A saved view the agent just created or edited: the table opens on it, and
@@ -208,7 +238,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
208238
if (payload.op === MothershipStreamV1ResourceOp.remove) {
209239
const resourceType = resource.type
210240
if (payload.effectId) {
211-
setResources((current) =>
241+
applyResourceEffect(ctx, (current) =>
212242
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
213243
)
214244
ctx.deps.setActiveResourceId((current) =>
@@ -263,7 +293,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
263293
? !resourcesRef.current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
264294
: addResource(resourceUpdate)
265295
if (payload.effectId) {
266-
setResources((current) => {
296+
applyResourceEffect(ctx, (current) => {
267297
const previous = current.find(
268298
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
269299
)

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): Str
2626
invalidateQueries: vi.fn(),
2727
cancelQueries: vi.fn(),
2828
setQueryData: vi.fn(),
29+
isFetching: vi.fn(() => 0),
2930
// double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures
3031
} as unknown as QueryClient,
3132
assistantId: 'assistant-1',

0 commit comments

Comments
 (0)