Skip to content

Commit 4e325b0

Browse files
committed
fix(mothership): expose canonical model hints in internal discovery
1 parent 292796b commit 4e325b0

5 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎apps/docs/content/docs/integrations/slack.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Use [Slack](https://www.slack.com/) to send and manage messages, work with canva
1515

1616
## Stream Trigger Responses
1717

18-
Custom-bot Slack triggers can stream workflow outputs directly back into the conversation that started a run. Enable **Stream response to Slack** on a Message, App Mention, or Assistant Thread Started trigger, then select the outputs to deliver.
18+
Custom-bot Slack triggers can stream workflow outputs directly back into the conversation that started a run. Turn on **Enable agent session** (`streamResponse`) on a Message, App Mention, or Assistant Thread Started trigger, then select the outputs to deliver in **Outputs to stream** (`streamOutputs`).
1919

2020
- A selected Agent output streams immediately as it is generated. If the Agent later calls a tool, any pre-tool commentary already streamed remains visible.
2121
- A selected non-streaming block output is sent when that block invocation completes.
@@ -24,7 +24,11 @@ Custom-bot Slack triggers can stream workflow outputs directly back into the con
2424
- Optional thinking and tool-call updates appear as Slack tasks in a timeline or plan.
2525
- Slack Agent Sessions remain in processing state for the run, return to active when it finishes, and the native Slack stop button cancels active workflow executions.
2626

27-
Automatic trigger responses require a custom bot created by the Slack setup wizard. They are not available with the shared Sim Slack app.
27+
Automatic trigger responses require a custom bot created by the Slack setup wizard. They are not available with the shared Sim Slack app. Where the deployment enables shared-app triggers, the Sim app can receive its subscribed event subset without automatic streaming.
28+
29+
The **Set Assistant Status** (`set_status`) action changes the assistant-thread indicator; it does not enable agent-session streaming. A separate Send Message action for an output already selected on the trigger sends an additional message.
30+
31+
Testing a block with sample input verifies its logic. To verify a Slack integration, check the real event's deployed workflow run and any execution or delivery errors, then confirm the reply in the originating conversation or thread.
2832

2933
## AI-Generated Content
3034

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from 'zod'
2+
import { v2BlockDetailSchema, v2BlockFieldSchema } from '@/lib/api/contracts/v2/catalog'
3+
4+
/** Internal discovery adds compact catalog hints without changing the public v2 response. */
5+
const mothershipBlockFieldSchema = v2BlockFieldSchema.extend({
6+
optionsAvailability: z.string().optional(),
7+
options: z
8+
.array(
9+
v2BlockFieldSchema.shape.options.unwrap().element.extend({
10+
recommended: z.literal(true).optional(),
11+
speedOptimized: z.literal(true).optional(),
12+
sunset: z.object({ status: z.enum(['legacy', 'deprecated']) }).optional(),
13+
})
14+
)
15+
.optional(),
16+
})
17+
18+
export const mothershipBlockDetailSchema = v2BlockDetailSchema.extend({
19+
inputSchema: z.array(mothershipBlockFieldSchema),
20+
})
21+
export type MothershipBlockDetail = z.infer<typeof mothershipBlockDetailSchema>

‎apps/sim/lib/mothership/agent-cli/curation.test.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2-
import type { V2BlockDetail } from '@/lib/api/contracts/v2/catalog'
2+
import { mothershipBlockDetailSchema } from '@/lib/api/contracts/mothership-catalog'
3+
import { type V2BlockDetail, v2BlockDetailSchema } from '@/lib/api/contracts/v2/catalog'
34
import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation'
5+
import { PROVIDER_DEFINITIONS } from '@/providers/models'
46

57
const { permissionConfig, denied } = vi.hoisted(() => ({
68
permissionConfig: { current: null as { deniedTools?: string[] } | null },
@@ -96,6 +98,61 @@ describe('curateBlockDetail', () => {
9698
expect(curated.toolIds).toEqual(['slack_send'])
9799
})
98100

101+
it('adds canonical model hints only on the internal curated response', async () => {
102+
const models = Object.values(PROVIDER_DEFINITIONS)
103+
.flatMap((provider) => provider.models)
104+
.filter((model) => model.sunset?.status !== 'deprecated')
105+
const original = {
106+
...blockDetail(),
107+
inputSchema: [
108+
{
109+
id: 'model',
110+
type: 'combobox',
111+
options: [...models.map((model) => ({ id: model.id })), { id: 'private-local-model' }],
112+
},
113+
],
114+
}
115+
const publicShape = v2BlockDetailSchema.parse(original)
116+
expect(publicShape).toEqual(original)
117+
const result = await curateBlockDetail(ok(JSON.stringify(original)), viewer)
118+
const detail = mothershipBlockDetailSchema.parse(JSON.parse(result.stdout))
119+
const field = detail.inputSchema[0]!
120+
expect(field.optionsAvailability).toContain(
121+
'deployment, provider credentials and model permissions'
122+
)
123+
expect(field.options).toHaveLength(original.inputSchema[0]!.options.length)
124+
for (const model of models) {
125+
const option = field.options!.find((option) => option.id === model.id)!
126+
expect(option.recommended).toBe(model.recommended || undefined)
127+
expect(option.speedOptimized).toBe(model.speedOptimized || undefined)
128+
expect(option.sunset).toEqual(model.sunset)
129+
expect(option).not.toHaveProperty('runnable')
130+
expect(option).not.toHaveProperty('pricing')
131+
}
132+
expect(field.options!.at(-1)).toEqual({ id: 'private-local-model' })
133+
expect(v2BlockDetailSchema.parse(detail)).toEqual(original)
134+
})
135+
136+
it('applies denied operation projection before adding model hints', async () => {
137+
permissionConfig.current = { deniedTools: ['slack_canvas'] }
138+
denied.current.needsProjection.set('slack', new Set(['canvas']))
139+
const original = blockDetail()
140+
original.inputSchema.push({
141+
id: 'model',
142+
type: 'combobox',
143+
options: [{ id: 'claude-sonnet-5' }],
144+
})
145+
const result = await curateBlockDetail(ok(JSON.stringify(original)), viewer)
146+
const detail = mothershipBlockDetailSchema.parse(JSON.parse(result.stdout))
147+
expect(Object.keys(detail.operations)).toEqual(['send'])
148+
expect(detail.inputSchema[0]!.options).toEqual([{ id: 'send' }])
149+
expect(detail.inputSchema[1]!.optionsAvailability).toBeDefined()
150+
denied.current.fullyDenied.add('slack')
151+
const blocked = await curateBlockDetail(ok(JSON.stringify(original)), viewer)
152+
expect(blocked.exitCode).toBe(1)
153+
expect(blocked.stdout).not.toContain('recommended')
154+
})
155+
99156
it('refuses a fully denied block', async () => {
100157
permissionConfig.current = { deniedTools: ['slack_send', 'slack_canvas'] }
101158
denied.current = { needsProjection: new Map(), fullyDenied: new Set(['slack']) }

‎apps/sim/lib/mothership/agent-cli/curation.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
*/
77

88
import { omit } from '@sim/utils/object'
9+
import { mothershipBlockDetailSchema } from '@/lib/api/contracts/mothership-catalog'
910
import { type V2BlockDetail, v2BlockDetailSchema } from '@/lib/api/contracts/v2/catalog'
11+
import { withModelHints } from '@/lib/mothership/agent-cli/model-hints'
1012
import { agentCliFail } from '@/lib/mothership/agent-cli/types'
1113
import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli'
1214
import { resolveDeniedBlockOperations } from '@/lib/mothership/integration-tool-projection'
@@ -27,7 +29,7 @@ function parseBlockDetail(stdout: string): V2BlockDetail | null {
2729
}
2830
}
2931

30-
export async function curateBlockDetail(
32+
async function curatePermittedBlockDetail(
3133
result: AgentCliRawResult,
3234
viewer: CurationViewer
3335
): Promise<AgentCliRawResult> {
@@ -63,3 +65,17 @@ export async function curateBlockDetail(
6365
}),
6466
}
6567
}
68+
69+
/** Static hints follow the existing authorized read and per-viewer operation projection. */
70+
export async function curateBlockDetail(
71+
result: AgentCliRawResult,
72+
viewer: CurationViewer
73+
): Promise<AgentCliRawResult> {
74+
const permitted = await curatePermittedBlockDetail(result, viewer)
75+
if (permitted.exitCode !== 0) return permitted
76+
const detail = parseBlockDetail(permitted.stdout)
77+
if (!detail) return permitted
78+
const enriched = withModelHints(detail)
79+
if (enriched === detail) return permitted
80+
return { ...permitted, stdout: JSON.stringify(mothershipBlockDetailSchema.parse(enriched)) }
81+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MothershipBlockDetail } from '@/lib/api/contracts/mothership-catalog'
2+
import { PROVIDER_DEFINITIONS } from '@/providers/models'
3+
4+
/** Enrich only authorized, already-published options; these facts never grant model access. */
5+
export function withModelHints(detail: MothershipBlockDetail): MothershipBlockDetail {
6+
if (
7+
!detail.inputSchema.some(
8+
(field) => field.id === 'model' && field.type === 'combobox' && field.options?.length
9+
)
10+
)
11+
return detail
12+
const models = new Map(
13+
Object.values(PROVIDER_DEFINITIONS).flatMap((provider) =>
14+
provider.models.map((model) => [model.id.toLowerCase(), model] as const)
15+
)
16+
)
17+
return {
18+
...detail,
19+
inputSchema: detail.inputSchema.map((field) => {
20+
if (field.id !== 'model' || field.type !== 'combobox' || !field.options?.length) return field
21+
return {
22+
...field,
23+
optionsAvailability:
24+
'Model availability depends on deployment, provider credentials and model permissions; catalog options do not establish access.',
25+
options: field.options.map((option) => {
26+
const model = models.get(option.id.toLowerCase())
27+
return {
28+
...option,
29+
...(model?.recommended ? { recommended: true as const } : {}),
30+
...(model?.speedOptimized ? { speedOptimized: true as const } : {}),
31+
...(model?.sunset ? { sunset: { ...model.sunset } } : {}),
32+
}
33+
}),
34+
}
35+
}),
36+
}
37+
}

0 commit comments

Comments
 (0)