fix(copilot): name a count-parallel's branch count count in the model's read view (#7082)

sanitizeForCopilot builds the workflow JSON the model reads before editing.
For a loop it emits `iterations` from `block.data.count`, which is right: the
loop write contract (components/blocks/loop.json) declares `iterations`, and
the edit path reads `params.inputs.iterations` back into `data.count`.

The parallel branch copied that line without renaming the field. But the
parallel write contract (components/blocks/parallel.json) declares `count`,
and the edit path reads `params.inputs.count` — so the model was shown a
branch count under a name its own write contract does not define. Echoing it
back drops the value silently: neither addSubflowConfig nor the update path
looks for `iterations` on a parallel.

Scoped to the copilot read/edit round-trip. sanitizeForExport is unaffected —
it preserves state.blocks wholesale, so JSON export and folder backup/restore
always carried the real data.count.

The sanitizer had zero parallelType coverage, which is how this drifted. Adds
three cases pinning both contracts and the collection variant; the parallel one
fails against the old code.
This commit is contained in:
Waleed
2026-08-25 15:41:34 -07:00
committed by GitHub
parent 32a15fde2f
commit ffe3ab1e6c
2 changed files with 57 additions and 1 deletions
@@ -184,6 +184,58 @@ describe('sanitizeForCopilot product-gated block inputs', () => {
})
})
describe('sanitizeForCopilot subflow config', () => {
/**
* The model's read view has to use the same field names as the write contract it is
* given, or an echoed-back edit is silently dropped. `components/blocks/parallel.json`
* declares `count`; `components/blocks/loop.json` declares `iterations`.
*/
it("names a count-parallel's branch count `count`, matching the parallel write contract", () => {
const state = makeSingleBlockWorkflow('parallel-1', {
type: 'parallel',
name: 'Parallel 1',
enabled: true,
subBlocks: {},
data: { parallelType: 'count', count: 5 },
})
expect(sanitizeForCopilot(state).blocks['parallel-1'].inputs).toEqual({
parallelType: 'count',
count: 5,
})
})
it("names a for-loop's trip count `iterations`, matching the loop write contract", () => {
const state = makeSingleBlockWorkflow('loop-1', {
type: 'loop',
name: 'Loop 1',
enabled: true,
subBlocks: {},
data: { loopType: 'for', count: 3 },
})
expect(sanitizeForCopilot(state).blocks['loop-1'].inputs).toEqual({
loopType: 'for',
iterations: 3,
})
})
it('exports a collection parallel without a branch count', () => {
const state = makeSingleBlockWorkflow('parallel-2', {
type: 'parallel',
name: 'Parallel 2',
enabled: true,
subBlocks: {},
data: { parallelType: 'collection', collection: '<start.items>' },
})
expect(sanitizeForCopilot(state).blocks['parallel-2'].inputs).toEqual({
parallelType: 'collection',
collection: '<start.items>',
})
})
})
/** Builds a one-block workflow for webhook-URL synthesis tests. */
function makeSingleBlockWorkflow(blockId: string, block: Record<string, unknown>): WorkflowState {
return {
@@ -591,7 +591,11 @@ export function sanitizeForCopilot(
loopInputs.parallelType = parallelType
// Only export fields relevant to the current parallelType
if (parallelType === 'count' && block.data?.count !== undefined) {
loopInputs.iterations = block.data.count
// `count`, not `iterations`: the parallel schema the model is given names this
// field `count` and the edit path reads it back under that name. A loop's
// equivalent field really is called `iterations` on both sides — copying that
// line here made the model's read view disagree with its own write contract.
loopInputs.count = block.data.count
}
if (parallelType === 'collection' && block.data?.collection !== undefined) {
loopInputs.collection = block.data.collection