Skip to content

Commit 8f56156

Browse files
committed
Catalog speaks the authoring vocabulary; grep pipe gains context flags
Root-cause fixes for the deep-eval lane's waste findings, not fallback patches. The agent called `blocks get loop` because add_block accepts type "loop" — the catalog omitting containers meant one surface taught a vocabulary the other 404'd on. Loop and Parallel now have catalog descriptors (list + get) whose option lists import the authoring constants (VALID_LOOP_TYPES/VALID_PARALLEL_TYPES exported from the editing operations), so the surfaces cannot drift; outputs document the container reference semantics. Live-verified via the CLI. The grep pipe gains -A/-B/-C context flags (window union, hit-based -c counting) — "grep" implies them, and their absence cost real calls. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 26784df commit 8f56156

7 files changed

Lines changed: 286 additions & 21 deletions

File tree

‎apps/sim/lib/catalog/application/catalog-reads.test.ts‎

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,9 @@ describe('catalog block and tool reads', () => {
286286

287287
expect(result.entries.map((entry) => entry.id)).toEqual([
288288
'custom_block_reports',
289+
'loop',
289290
'notion',
291+
'parallel',
290292
'slack',
291293
])
292294
expect(result.hasMore).toBe(false)
@@ -296,7 +298,7 @@ describe('catalog block and tool reads', () => {
296298
it('accepts a workspace API key, which has no user for permission groups to key on', async () => {
297299
const result = await listCatalogBlocks.execute({ principal: workspaceKey, input: listInput })
298300

299-
expect(result.entries).toHaveLength(3)
301+
expect(result.entries).toHaveLength(5)
300302
expect(mocks.allowedIntegrationTypes).toHaveBeenCalledWith(workspaceKey, WORKSPACE_ID)
301303
expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ orgId: 'org-1' })
302304
})
@@ -313,7 +315,9 @@ describe('catalog block and tool reads', () => {
313315
const sources = Object.fromEntries(result.entries.map((entry) => [entry.id, entry.source]))
314316
expect(sources).toEqual({
315317
custom_block_reports: 'custom',
318+
loop: 'builtin',
316319
notion: 'builtin',
320+
parallel: 'builtin',
317321
slack: 'builtin',
318322
})
319323
})
@@ -338,7 +342,7 @@ describe('catalog block and tool reads', () => {
338342
mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock])
339343

340344
const result = await listCatalogBlocks.execute({ principal: session, input: listInput })
341-
expect(result.entries.map((entry) => entry.id)).toEqual(['slack'])
345+
expect(result.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack'])
342346

343347
await expect(
344348
getCatalogBlock.execute({
@@ -387,7 +391,7 @@ describe('catalog block and tool reads', () => {
387391
mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack_v2']))
388392

389393
const result = await listCatalogBlocks.execute({ principal: session, input: listInput })
390-
expect(result.entries.map((entry) => entry.id)).toEqual(['slack'])
394+
expect(result.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack'])
391395

392396
await expect(
393397
getCatalogBlock.execute({
@@ -401,7 +405,12 @@ describe('catalog block and tool reads', () => {
401405
mocks.isDeploymentAvailable.mockImplementation((type: string) => type !== 'notion')
402406

403407
const result = await listCatalogBlocks.execute({ principal: session, input: listInput })
404-
expect(result.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'slack'])
408+
expect(result.entries.map((entry) => entry.id)).toEqual([
409+
'custom_block_reports',
410+
'loop',
411+
'parallel',
412+
'slack',
413+
])
405414
})
406415

407416
it('narrows to trigger-capable blocks without a second endpoint', async () => {
@@ -424,15 +433,35 @@ describe('catalog block and tool reads', () => {
424433
principal: session,
425434
input: { ...listInput, limit: 2 },
426435
})
427-
expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'notion'])
436+
expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'loop'])
428437
expect(first.hasMore).toBe(true)
429438

430439
const second = await listCatalogBlocks.execute({
431440
principal: session,
432441
input: { ...listInput, limit: 2, offset: 2 },
433442
})
434-
expect(second.entries.map((entry) => entry.id)).toEqual(['slack'])
435-
expect(second.hasMore).toBe(false)
443+
expect(second.entries.map((entry) => entry.id)).toEqual(['notion', 'parallel'])
444+
expect(second.hasMore).toBe(true)
445+
})
446+
447+
it('answers for the loop and parallel containers with their authoring shape', async () => {
448+
const { block: loop } = await getCatalogBlock.execute({
449+
principal: session,
450+
input: { workspaceId: WORKSPACE_ID, blockId: 'loop' },
451+
})
452+
expect(loop.id).toBe('loop')
453+
expect(
454+
loop.inputSchema.find((field) => field.id === 'loopType')?.options?.map((o) => o.id)
455+
).toEqual(['for', 'forEach', 'while', 'doWhile'])
456+
expect(Object.keys(loop.outputs)).toContain('results')
457+
458+
const { block: parallel } = await getCatalogBlock.execute({
459+
principal: session,
460+
input: { workspaceId: WORKSPACE_ID, blockId: 'parallel' },
461+
})
462+
expect(
463+
parallel.inputSchema.find((field) => field.id === 'parallelType')?.options?.map((o) => o.id)
464+
).toEqual(['count', 'collection'])
436465
})
437466

438467
it('reads one block with its operations and tools resolved from metadata', async () => {
@@ -566,7 +595,12 @@ describe('catalog block and tool reads', () => {
566595
* positive by code unit. Pinning the code-unit answer is what makes an
567596
* offset cursor name the same row on every instance, whatever its `LANG`.
568597
*/
569-
expect(result.entries.map((entry) => entry.name)).toEqual(['Banana', 'apple'])
598+
expect(result.entries.map((entry) => entry.name)).toEqual([
599+
'Banana',
600+
'Loop',
601+
'Parallel',
602+
'apple',
603+
])
570604
})
571605

572606
it('reports no hosted key on a deployment that supplies none', async () => {

‎apps/sim/lib/catalog/application/get-block.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@/lib/catalog/application/catalog-context'
77
import { catalogOperations } from '@/lib/catalog/application/operations'
88
import { type CatalogBlockDetail, projectBlockDetail } from '@/lib/catalog/projection/block-detail'
9+
import { getContainerBlockDetail } from '@/lib/catalog/projection/container-blocks'
910
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
1011
import { isHosted } from '@/lib/core/config/env-flags'
1112
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -43,6 +44,10 @@ export const getCatalogBlock = defineAuthorizedWorkspaceUseCase({
4344
const gate = await resolveCatalogGate(principal, context)
4445

4546
const detail = await withCatalogBlockScope(gate, async () => {
47+
// Containers (loop/parallel) are authorable types outside the registry;
48+
// the catalog answers for the same vocabulary add_block accepts.
49+
const container = getContainerBlockDetail(input.blockId)
50+
if (container) return container
4651
const block = getLatestBlockForViewer(input.blockId)
4752
if (!block || !isBlockVisibleToCaller(block, gate)) return null
4853
return projectBlockDetail(block, { deployment: { hostedKeys: isHosted } })

‎apps/sim/lib/catalog/application/list-blocks.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type CatalogBlockSummary,
1818
projectBlockSummary,
1919
} from '@/lib/catalog/projection/block-summary'
20+
import { CONTAINER_BLOCK_SUMMARIES } from '@/lib/catalog/projection/container-blocks'
2021
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
2122
import { getAllBlocks } from '@/blocks/registry'
2223

@@ -70,11 +71,14 @@ export const listCatalogBlocks = defineAuthorizedWorkspaceUseCase({
7071
const search = normalizeCatalogSearch(input.search)
7172
const gate = await resolveCatalogGate(principal, context)
7273

73-
const summaries = await withCatalogBlockScope(gate, async () =>
74-
getAllBlocks()
74+
const summaries = await withCatalogBlockScope(gate, async () => [
75+
...getAllBlocks()
7576
.filter((block) => isBlockVisibleToCaller(block, gate))
76-
.map(projectBlockSummary)
77-
)
77+
.map(projectBlockSummary),
78+
// Containers are authorable types (add_block accepts them) that live outside
79+
// the registry — the catalog speaks the same vocabulary as authoring.
80+
...CONTAINER_BLOCK_SUMMARIES,
81+
])
7882

7983
const filtered = summaries.filter(
8084
(block) =>
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { CatalogBlockDetail } from '@/lib/catalog/projection/block-detail'
2+
import type { CatalogBlockSummary } from '@/lib/catalog/projection/block-summary'
3+
import { VALID_LOOP_TYPES, VALID_PARALLEL_TYPES } from '@/lib/workflows/editing/operations'
4+
5+
/**
6+
* Loop and Parallel are containers, not registry blocks — yet the authoring
7+
* surface accepts them as `type: "loop"` / `type: "parallel"` in add_block
8+
* operations, and the canvas presents them alongside blocks. A catalog that
9+
* omits them teaches one vocabulary and 404s on the other; these descriptors
10+
* make the catalog speak the authoring surface's language. Their option lists
11+
* import the authoring constants directly, so the surfaces cannot drift.
12+
*/
13+
14+
const loopSummary: CatalogBlockSummary = {
15+
id: 'loop',
16+
name: 'Loop',
17+
description:
18+
'Container that runs its child blocks repeatedly: a fixed count (for), once per item of a collection (forEach), or while a condition holds (while/doWhile).',
19+
category: 'blocks',
20+
source: 'builtin',
21+
triggerAllowed: false,
22+
triggerCapable: false,
23+
preview: false,
24+
tags: [],
25+
triggerIds: [],
26+
toolIds: [],
27+
operationIds: [],
28+
}
29+
30+
const parallelSummary: CatalogBlockSummary = {
31+
id: 'parallel',
32+
name: 'Parallel',
33+
description:
34+
'Container that runs its child blocks in concurrent branches: a fixed count, or one branch per item of a collection.',
35+
category: 'blocks',
36+
source: 'builtin',
37+
triggerAllowed: false,
38+
triggerCapable: false,
39+
preview: false,
40+
tags: [],
41+
triggerIds: [],
42+
toolIds: [],
43+
operationIds: [],
44+
}
45+
46+
const CONTAINER_USAGE =
47+
'Containers are authored through workflows operations apply (add_block with this type, children via nestedNodes or parentId), not placed like tool blocks. Wire the body through the container handles (loop-start-source / loop-end-source, parallel-start-source / parallel-end-source); an unwired body never runs.'
48+
49+
const loopDetail: CatalogBlockDetail = {
50+
...loopSummary,
51+
bestPractices: CONTAINER_USAGE,
52+
inputSchema: [
53+
{
54+
id: 'loopType',
55+
type: 'dropdown',
56+
title: 'Loop type',
57+
required: true,
58+
options: VALID_LOOP_TYPES.map((id) => ({ id })),
59+
description: 'How iteration is driven. Defaults to "for".',
60+
},
61+
{
62+
id: 'iterations',
63+
type: 'short-input',
64+
title: 'Iterations',
65+
condition: { field: 'loopType', value: 'for' },
66+
description: 'Number of iterations for a for loop.',
67+
},
68+
{
69+
id: 'collection',
70+
type: 'long-input',
71+
title: 'Collection',
72+
condition: { field: 'loopType', value: 'forEach' },
73+
description: 'Array (or reference resolving to one) iterated by a forEach loop.',
74+
},
75+
{
76+
id: 'condition',
77+
type: 'long-input',
78+
title: 'Condition',
79+
condition: { field: 'loopType', value: ['while', 'doWhile'] },
80+
description:
81+
'Continue condition for while/doWhile. May not reference blocks inside the loop body.',
82+
},
83+
],
84+
operationInputSchema: {},
85+
inputDefinitions: {},
86+
operations: {},
87+
tools: [],
88+
triggers: [],
89+
outputs: {
90+
index: {
91+
type: 'number',
92+
description: 'Current iteration index inside the body (<loop.index>).',
93+
},
94+
currentItem: {
95+
type: 'any',
96+
description: 'Current item inside a forEach body (<loop.currentItem>).',
97+
},
98+
items: {
99+
type: 'array',
100+
description: 'The full collection inside a forEach body (<loop.items>).',
101+
},
102+
results: {
103+
type: 'array',
104+
description:
105+
'All iteration results, referenced OUTSIDE the loop by its block name (<myLoop.results>).',
106+
},
107+
},
108+
}
109+
110+
const parallelDetail: CatalogBlockDetail = {
111+
...parallelSummary,
112+
bestPractices: CONTAINER_USAGE,
113+
inputSchema: [
114+
{
115+
id: 'parallelType',
116+
type: 'dropdown',
117+
title: 'Parallel type',
118+
required: true,
119+
options: VALID_PARALLEL_TYPES.map((id) => ({ id })),
120+
description: 'How branches are created. Defaults to "count".',
121+
},
122+
{
123+
id: 'count',
124+
type: 'short-input',
125+
title: 'Count',
126+
condition: { field: 'parallelType', value: 'count' },
127+
description: 'Number of concurrent branches.',
128+
},
129+
{
130+
id: 'collection',
131+
type: 'long-input',
132+
title: 'Collection',
133+
condition: { field: 'parallelType', value: 'collection' },
134+
description: 'Array (or reference resolving to one) fanned out one branch per item.',
135+
},
136+
],
137+
operationInputSchema: {},
138+
inputDefinitions: {},
139+
operations: {},
140+
tools: [],
141+
triggers: [],
142+
outputs: {
143+
index: { type: 'number', description: 'Branch index inside the body (<parallel.index>).' },
144+
currentItem: {
145+
type: 'any',
146+
description: 'Current item inside a collection branch (<parallel.currentItem>).',
147+
},
148+
results: {
149+
type: 'array',
150+
description:
151+
'All branch results, referenced OUTSIDE by the block name (<myParallel.results>).',
152+
},
153+
},
154+
}
155+
156+
export const CONTAINER_BLOCK_SUMMARIES: CatalogBlockSummary[] = [loopSummary, parallelSummary]
157+
158+
const CONTAINER_BLOCK_DETAILS: Record<string, CatalogBlockDetail> = {
159+
loop: loopDetail,
160+
parallel: parallelDetail,
161+
}
162+
163+
export function getContainerBlockDetail(blockId: string): CatalogBlockDetail | null {
164+
return CONTAINER_BLOCK_DETAILS[blockId] ?? null
165+
}

‎apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,24 @@ describe('applyPipeline', () => {
100100
expect(applyPipeline('', [['head', '-n', '5']]).ok).toBe(false)
101101
})
102102
})
103+
104+
describe('grep context flags', () => {
105+
const input = 'a\nb\nHIT\nc\nd\ne\nHIT\nf'
106+
it('-A appends trailing context lines', () => {
107+
const r = applyPipeline(input, [['grep', '-A', '1', 'HIT']])
108+
expect(r).toEqual({ ok: true, stdout: 'HIT\nc\nHIT\nf' })
109+
})
110+
it('-B and -C select windows without duplicating overlaps', () => {
111+
const r = applyPipeline('x\nHIT\nHIT\ny', [['grep', '-C', '1', 'HIT']])
112+
expect(r).toEqual({ ok: true, stdout: 'x\nHIT\nHIT\ny' })
113+
})
114+
it('-c counts hits, not context lines', () => {
115+
const r = applyPipeline(input, [['grep', '-c', '-A', '2', 'HIT']])
116+
expect(r).toEqual({ ok: true, stdout: '2' })
117+
})
118+
it('rejects a negative context count with usage guidance', () => {
119+
const r = applyPipeline(input, [['grep', '-A', '-2', 'HIT']])
120+
expect(r.ok).toBe(false)
121+
if (!r.ok) expect(r.error).toContain('-A needs a non-negative number')
122+
})
123+
})

0 commit comments

Comments
 (0)