improvement(desktop, executions): fix desktop update script and throw error when thinking enabled on stream false (#6578)

* Fix update check

* fix include tool calls error

* fix comments
This commit is contained in:
Siddharth Ganesan
2026-08-11 18:48:16 -07:00
committed by GitHub
parent e7590c828c
commit 7f39678264
9 changed files with 139 additions and 56 deletions
+5 -5
View File
@@ -1021,7 +1021,7 @@
"post": {
"operationId": "executeWorkflowV2",
"summary": "Execute Workflow",
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"tags": ["Workflows"],
"security": [
{
@@ -1069,7 +1069,7 @@
],
"requestBody": {
"required": true,
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"content": {
"application/json": {
"schema": {
@@ -3861,12 +3861,12 @@
},
"includeThinking": {
"default": false,
"description": "Include model reasoning events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
"description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
"type": "boolean"
},
"includeToolCalls": {
"default": false,
"description": "Include tool-call events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
"description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
"type": "boolean"
},
"includeFileBase64": {
@@ -3882,7 +3882,7 @@
},
"additionalProperties": false,
"title": "Execute workflow request",
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
"examples": [
{
"input": {
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { setEnv } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MANIFEST_ASSET_NAME } from '@/lib/desktop/update-feed'
@@ -27,8 +28,11 @@ function manifest(version: string) {
return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal-mac.zip`].join('\n')
}
async function getFeed(hostname: string): Promise<Response> {
return GET(new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`), undefined)
async function getFeed(hostname: string, headers?: HeadersInit): Promise<Response> {
return GET(
new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`, { headers }),
undefined
)
}
describe('desktop update manifest route', () => {
@@ -37,6 +41,7 @@ describe('desktop update manifest route', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
setEnv({ APPCONFIG_ENVIRONMENT: undefined })
})
afterEach(() => {
@@ -44,10 +49,11 @@ describe('desktop update manifest route', () => {
})
it.each([
['www.dev.sim.ai', 'v1.2.0-dev.4', '1.2.0-dev.4'],
['www.staging.sim.ai', 'v1.2.0-staging.5', '1.2.0-staging.5'],
['www.sim.ai', 'v1.1.0', '1.1.0'],
])('serves the newest release for %s', async (hostname, tag, version) => {
['dev', 'v1.2.0-dev.4', '1.2.0-dev.4'],
['staging', 'v1.2.0-staging.5', '1.2.0-staging.5'],
['production', 'v1.1.0', '1.1.0'],
])('serves the newest release for the %s deployment', async (environment, tag, version) => {
setEnv({ APPCONFIG_ENVIRONMENT: environment })
fetchMock.mockImplementation(async (input: string | URL | Request) => {
const url = String(input)
if (url === RELEASES_URL) {
@@ -63,7 +69,7 @@ describe('desktop update manifest route', () => {
return new Response(null, { status: 404 })
})
const response = await getFeed(hostname)
const response = await getFeed('internal.service.local')
const body = await response.text()
expect(response.status).toBe(200)
@@ -74,6 +80,62 @@ describe('desktop update manifest route', () => {
)
})
it.each([
['dev', 'www.staging.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'],
['staging', 'www.sim.ai:443', 'v1.2.0-staging.5', '1.2.0-staging.5'],
['production', 'www.dev.sim.ai:443', 'v1.1.0', '1.1.0'],
])(
'ignores request-controlled host headers for the %s deployment',
async (environment, spoofedHost, tag, version) => {
setEnv({ APPCONFIG_ENVIRONMENT: environment })
fetchMock.mockImplementation(async (input: string | URL | Request) => {
const url = String(input)
if (url === RELEASES_URL) {
return Response.json([
release('v1.2.0-dev.4'),
release('v1.2.0-staging.5'),
release('v1.1.0'),
])
}
if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) {
return new Response(manifest(version))
}
return new Response(null, { status: 404 })
})
const response = await getFeed('internal.service.local', {
host: spoofedHost,
'x-forwarded-host': `attacker.example, ${spoofedHost}`,
})
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release')
expect(body).toContain(`version: ${version}`)
}
)
it('defaults self-hosted deployments to the stable channel', async () => {
fetchMock.mockImplementation(async (input: string | URL | Request) => {
const url = String(input)
if (url === RELEASES_URL) {
return Response.json([release('v1.2.0-dev.4'), release('v1.1.0')])
}
if (url === `https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`) {
return new Response(manifest('1.1.0'))
}
return new Response(null, { status: 404 })
})
const response = await getFeed('internal.service.local', {
host: 'www.dev.sim.ai:443',
'x-forwarded-host': 'www.dev.sim.ai:443',
})
expect(response.status).toBe(200)
expect(await response.text()).toContain('version: 1.1.0')
})
it('reports an authoritative no-release result for production with only prereleases', async () => {
fetchMock.mockResolvedValueOnce(
Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')])
@@ -88,6 +150,7 @@ describe('desktop update manifest route', () => {
})
it('rejects a manifest whose version does not match its selected release', async () => {
setEnv({ APPCONFIG_ENVIRONMENT: 'dev' })
fetchMock
.mockResolvedValueOnce(Response.json([release('v1.2.0-dev.4')]))
.mockResolvedValueOnce(new Response(manifest('1.2.0-staging.5')))
@@ -1,8 +1,9 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
channelForHostname,
channelForDeploymentEnvironment,
DESKTOP_RELEASE_REPO,
type DesktopReleaseCandidate,
MANIFEST_ASSET_NAME,
@@ -29,12 +30,14 @@ const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/r
* design: the updater's HTTP client carries no session, and the response
* only describes public GitHub release artifacts.
*/
export const GET = withRouteHandler(async (request: NextRequest): Promise<Response> => {
// The same deployment configuration can be promoted across environments, so
// its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed.
// The hostname the installed shell actually requested is the channel:
// dev -> dev, staging -> staging, and prod/self-hosted -> stable.
const channel = channelForHostname(request.nextUrl.hostname)
export const GET = withRouteHandler(async (_request: NextRequest): Promise<Response> => {
/**
* Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime,
* so it stays correct when the same image is promoted across environments.
* Request host headers are intentionally excluded: this public route must not
* let a caller choose which app-identity release the feed serves.
*/
const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT)
// A token raises the GitHub API quota from 60/h per NAT IP to 5000/h.
// Optional: the repo is public, so the feed works without one.
@@ -408,6 +408,27 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
expect(mockPreprocessExecution).not.toHaveBeenCalled()
})
it.each(['includeThinking', 'includeToolCalls'])(
'rejects %s unless stream is true before checking the protocol header',
async (option) => {
const withProtocol = await callExecute(
{ [option]: true },
{ 'X-Sim-Stream-Protocol': 'agent-events-v1' }
)
const withoutProtocol = await callExecute({ [option]: true })
expect(withProtocol.status).toBe(400)
expect((await withProtocol.json()).error.message).toBe(
'includeThinking and includeToolCalls require stream: true'
)
expect(withoutProtocol.status).toBe(400)
expect((await withoutProtocol.json()).error.message).toBe(
'includeThinking and includeToolCalls require stream: true'
)
expect(mockPreprocessExecution).not.toHaveBeenCalled()
}
)
it('conceals a workspace-key/workflow mismatch as not found', async () => {
mockAuthenticateV2ApiKey.mockResolvedValue({
principal: {
@@ -216,13 +216,14 @@ export const POST = withRouteHandler(
'Async execution does not support streaming or output-shaping options'
)
}
if (
hasAgentStreamPolicy({
includeThinking: body.includeThinking,
includeToolCalls: body.includeToolCalls,
}) &&
!clientAcceptsAgentStreamProtocol(req.headers)
) {
const hasAgentStreamOptions = hasAgentStreamPolicy({
includeThinking: body.includeThinking,
includeToolCalls: body.includeToolCalls,
})
if (hasAgentStreamOptions && !body.stream) {
return v2Error('BAD_REQUEST', 'includeThinking and includeToolCalls require stream: true')
}
if (hasAgentStreamOptions && !clientAcceptsAgentStreamProtocol(req.headers)) {
return v2Error(
'BAD_REQUEST',
`includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.`
@@ -12,6 +12,7 @@ import {
WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND,
} from '@/lib/api/contracts/v2/openapi/shared'
import {
EXECUTE_OPTION_CONSTRAINTS,
v2CancelWorkflowRunContract,
v2CreateWorkflowContract,
v2CreateWorkflowFolderContract,
@@ -515,8 +516,7 @@ const routes = [
workflowOperation({
operationId: 'executeWorkflowV2',
summary: 'Execute Workflow',
description:
'Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: "failed"` and `error.code: "TIMEOUT"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: "RUN_ID_CONFLICT"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.',
description: `Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. The optional \`X-Run-Id\` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with \`error.details.code: "RUN_ID_CONFLICT"\` and never replays the earlier run. ${EXECUTE_OPTION_CONSTRAINTS}`,
errors: [
'BadRequest',
'Unauthorized',
+4 -4
View File
@@ -748,7 +748,7 @@ export type V2ExecutionError = z.output<typeof v2ExecutionErrorSchema>
* description cannot drift from each other.
*/
export const EXECUTE_OPTION_CONSTRAINTS =
'Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.'
'Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.'
/**
* Strict public execute body. Async is body-selected (`async: true`) — v2 has
@@ -756,7 +756,7 @@ export const EXECUTE_OPTION_CONSTRAINTS =
* (triggerType, draft state, deployment pinning) are NEVER wire fields; they
* are typed options on the execution service.
*
* The five rejected option combinations are enumerated in
* The six rejected option combinations are enumerated in
* {@link EXECUTE_OPTION_CONSTRAINTS} and enforced by the route after parsing.
*/
export const v2ExecuteWorkflowBodySchema = z
@@ -806,14 +806,14 @@ export const v2ExecuteWorkflowBodySchema = z
.optional()
.default(false)
.describe(
'Include model reasoning events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.'
'Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.'
),
includeToolCalls: z
.boolean()
.optional()
.default(false)
.describe(
'Include tool-call events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.'
'Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.'
),
includeFileBase64: z
.boolean()
+10 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
channelForHostname,
channelForDeploymentEnvironment,
channelOfVersion,
MANIFEST_ASSET_NAME,
rewriteManifestUrls,
@@ -22,19 +22,17 @@ function release(
}
}
describe('channelForHostname', () => {
it('maps hosted environments to their channels', () => {
expect(channelForHostname('dev.sim.ai')).toBe('dev')
expect(channelForHostname('www.dev.sim.ai')).toBe('dev')
expect(channelForHostname('staging.sim.ai')).toBe('staging')
expect(channelForHostname('www.staging.sim.ai')).toBe('staging')
expect(channelForHostname('sim.ai')).toBe('latest')
expect(channelForHostname('www.sim.ai')).toBe('latest')
describe('channelForDeploymentEnvironment', () => {
it('maps hosted deployment environments to their channels', () => {
expect(channelForDeploymentEnvironment('dev')).toBe('dev')
expect(channelForDeploymentEnvironment('staging')).toBe('staging')
expect(channelForDeploymentEnvironment('production')).toBe('latest')
})
it('defaults self-hosted and local deployments to stable', () => {
expect(channelForHostname('sim.example.com')).toBe('latest')
expect(channelForHostname('localhost')).toBe('latest')
it('defaults self-hosted, local, and unknown deployments to stable', () => {
expect(channelForDeploymentEnvironment(undefined)).toBe('latest')
expect(channelForDeploymentEnvironment('')).toBe('latest')
expect(channelForDeploymentEnvironment('unknown')).toBe('latest')
})
})
+9 -12
View File
@@ -6,9 +6,9 @@
* GitHub feed, so each environment independently controls which shell build
* its clients are offered. The environment IS the channel:
*
* - dev.sim.ai → `dev` (per-push prerelease builds from `dev`)
* - staging.sim.ai → `staging` (per-push prerelease builds from `staging`)
* - sim.ai + self-hosted/unknown → `latest` (stable vX.Y.Z releases only)
* - hosted `dev` deployment → `dev` (per-push prerelease builds from `dev`)
* - hosted `staging` deployment → `staging` (per-push prerelease builds from `staging`)
* - production + self-hosted → `latest` (stable vX.Y.Z releases only)
*
* Artifacts stay on GitHub Releases (dumb storage); the feed route picks the
* right release for its channel and serves that release's electron-updater
@@ -28,15 +28,12 @@ export const DESKTOP_RELEASE_REPO = 'simstudioai/sim'
export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest'
/** Maps a deployment hostname to its desktop update channel. */
export function channelForHostname(hostname: string): DesktopUpdateChannel {
const host = hostname.toLowerCase()
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) {
return 'dev'
}
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) {
return 'staging'
}
/** Maps Sim's server-controlled deployment environment to its update channel. */
export function channelForDeploymentEnvironment(
environment: string | undefined
): DesktopUpdateChannel {
if (environment === 'dev') return 'dev'
if (environment === 'staging') return 'staging'
return 'latest'
}