@things-factory/figure-service 10.1.167 → 10.1.169
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/figure/figure-context.d.ts +108 -0
- package/dist-server/service/figure/figure-context.js +368 -0
- package/dist-server/service/figure/figure-context.js.map +1 -0
- package/dist-server/service/figure/figure-format.js +10 -16
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +2 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-proposal.js +4 -2
- package/dist-server/service/figure/figure-proposal.js.map +1 -1
- package/dist-server/service/figure/figure-propose-steps.d.ts +34 -6
- package/dist-server/service/figure/figure-propose-steps.js +128 -17
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +197 -49
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/figure/figure-context.test.ts +288 -0
- package/server/service/figure/figure-context.ts +447 -0
- package/server/service/figure/figure-format.test.ts +18 -1
- package/server/service/figure/figure-format.ts +10 -16
- package/server/service/figure/figure-llm-smoke.test.ts +82 -13
- package/server/service/figure/figure-mutation.ts +3 -3
- package/server/service/figure/figure-proposal.ts +4 -2
- package/server/service/figure/figure-propose-steps.test.ts +146 -6
- package/server/service/figure/figure-propose-steps.ts +145 -26
- package/server/service/figure/figure-tools-door-db.test.ts +227 -0
- package/server/service/figure/figure-tools.test.ts +382 -22
- package/server/service/figure/figure-tools.ts +218 -50
- package/translations/en.json +11 -9
- package/translations/es.json +11 -9
- package/translations/ja.json +11 -9
- package/translations/ko.json +11 -9
- package/translations/ms.json +11 -9
- package/translations/zh.json +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.169",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"test:templates": "node --test scripts/templates.test.mts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@hatiolab/figure-model": "^0.
|
|
28
|
+
"@hatiolab/figure-model": "^0.7.1",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
30
|
+
"@things-factory/ai-client-base": "^10.1.169",
|
|
31
31
|
"@things-factory/attachment-base": "^10.1.167",
|
|
32
32
|
"@things-factory/auth-base": "^10.1.167",
|
|
33
33
|
"@things-factory/env": "^10.1.149",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"jszip": "^3.10.1",
|
|
39
39
|
"koa-router": "^7.4.0"
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "9d71d02b443f3e0080b25de1768b982c51a64111"
|
|
42
42
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figure-context — what of a figure the model is shown (ADR-0061 2026-10-16 decision 4).
|
|
3
|
+
*
|
|
4
|
+
* The kernel keeps a figure up to 1 MiB; the model is shown at most FIGURE_CONTEXT_BUDGET of it in one read. A figure past
|
|
5
|
+
* that is read as an outline and the parts asked for, and a proposal may change only the parts it was shown. The figures
|
|
6
|
+
* here are made by the kernel's own commands, as a person makes them in the modeller: a rack of shelves, each fastened on
|
|
7
|
+
* the one below, grown until it crosses the budget.
|
|
8
|
+
*/
|
|
9
|
+
import { FIGURE_PROPOSAL_KINDS, applyFigureProposal, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
10
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
11
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
12
|
+
|
|
13
|
+
import { FIGURE_CONTEXT_BUDGET, figureViewOf, unreadPartEdits, unreadSectionEdits } from './figure-context'
|
|
14
|
+
import { ProposeFigureFailure, judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
|
|
15
|
+
import { parseSource } from './figure-format'
|
|
16
|
+
|
|
17
|
+
const shelf = (i: number) => [
|
|
18
|
+
{ kind: 'add-part', part: { id: `shelf-${i}`, kind: 'box', dimensions: { width: 1200, height: 40, depth: 600 } } },
|
|
19
|
+
i === 0
|
|
20
|
+
? { kind: 'attach', part: 'shelf-0', face: 'bottom', to: { plane: 'mounting-plane' } }
|
|
21
|
+
: { kind: 'attach', part: `shelf-${i}`, face: 'bottom', to: { part: `shelf-${i - 1}`, face: 'top' }, gap: 300 }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** The last rack the model is shown whole, and the first it is not: one shelf apart. */
|
|
25
|
+
let under: FigureAsset
|
|
26
|
+
let over: FigureAsset
|
|
27
|
+
/** A small rack whose one seat has a 130,000-character label: large, and none of it in its parts (audit D4-R2). */
|
|
28
|
+
let labelled: FigureAsset
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
let small = createFigureAsset('Rack')
|
|
31
|
+
for (let i = 0; i < 3; i++) small = applyFigureProposal(small, shelf(i) as never)
|
|
32
|
+
labelled = applyFigureProposal(small, [
|
|
33
|
+
{ kind: 'add-binding', id: 'seat', label: 'x'.repeat(130_000), role: 'seat', part: 'shelf-2', face: 'top', accepts: ['box'], capacity: 1 }
|
|
34
|
+
] as never)
|
|
35
|
+
|
|
36
|
+
let rack = createFigureAsset('Rack')
|
|
37
|
+
for (let i = 0; ; i++) {
|
|
38
|
+
const next = applyFigureProposal(rack, shelf(i) as never)
|
|
39
|
+
if (serializeFigureAsset(next).length > FIGURE_CONTEXT_BUDGET) {
|
|
40
|
+
under = rack
|
|
41
|
+
over = next
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
rack = next
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const size = (asset: FigureAsset) => serializeFigureAsset(asset).length
|
|
49
|
+
const partsOf = (asset: FigureAsset) => asset.appearance.map(a => a.target)
|
|
50
|
+
|
|
51
|
+
describe('the budget is the model’s, not the kernel’s', () => {
|
|
52
|
+
it('the racks sit one shelf either side of the budget, and the kernel takes both', () => {
|
|
53
|
+
expect(size(under)).toBeLessThanOrEqual(FIGURE_CONTEXT_BUDGET)
|
|
54
|
+
expect(size(over)).toBeGreaterThan(FIGURE_CONTEXT_BUDGET)
|
|
55
|
+
expect(size(over) - size(under)).toBeLessThan(5_000)
|
|
56
|
+
expect(() => parseSource(serializeFigureAsset(over))).not.toThrow()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('just under: the whole source', () => {
|
|
60
|
+
const view = figureViewOf(under)
|
|
61
|
+
expect(view.whole).toBe(true)
|
|
62
|
+
expect(view.source).toBe(under)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('just over: an outline that names every part, inside the budget, without the graph', () => {
|
|
66
|
+
const view = figureViewOf(over)
|
|
67
|
+
expect(view.whole).toBe(false)
|
|
68
|
+
expect(view.source).toBeUndefined()
|
|
69
|
+
expect(JSON.stringify(view.outline).length).toBeLessThan(FIGURE_CONTEXT_BUDGET)
|
|
70
|
+
expect(view.outline!.chars).toBe(size(over))
|
|
71
|
+
expect(view.outline!.parts.map(p => p.id)).toEqual(partsOf(over))
|
|
72
|
+
expect(view.outline!.parts[1]).toMatchObject({ id: 'shelf-1', on: 'shelf-0.top', joint: { id: 'shelf-1', type: 'fixed' } })
|
|
73
|
+
expect(view.outline!.parts[0].on).toBe('mounting-plane')
|
|
74
|
+
expect(view.outline!.document.model.nodes).toBe(over.document.model.nodes.length)
|
|
75
|
+
expect(view.outline!.palette).toEqual(over.palette)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a part read is its own records: its look, its fastenings, the nodes under its name', () => {
|
|
79
|
+
const view = figureViewOf(over, { parts: ['shelf-3'] })
|
|
80
|
+
const read = view.parts!['shelf-3']
|
|
81
|
+
expect(read.appearance.target).toBe('shelf-3')
|
|
82
|
+
expect(read.joints.map((j: any) => j.id).sort()).toEqual(['shelf-3', 'shelf-4'])
|
|
83
|
+
expect(read.nodes.length).toBeGreaterThan(0)
|
|
84
|
+
expect(read.nodes.every((n: any) => n.id === 'shelf-3' || n.id.startsWith('shelf-3.'))).toBe(true)
|
|
85
|
+
expect(read.nodes.some((n: any) => n.id.startsWith('shelf-30.'))).toBe(false)
|
|
86
|
+
expect(view.shown).toEqual(['shelf-3'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('what is not a part, or does not fit beside the rest, is said, not shown', () => {
|
|
90
|
+
const view = figureViewOf(over, { parts: ['shelf-2', 'ghost', ...partsOf(over)] })
|
|
91
|
+
expect(view.notRead).toContainEqual({ part: 'ghost', reason: expect.stringMatching(/not a part/) })
|
|
92
|
+
expect(view.notRead!.some(n => /does not fit/.test(n.reason))).toBe(true)
|
|
93
|
+
expect(JSON.stringify(view).length).toBeLessThanOrEqual(FIGURE_CONTEXT_BUDGET)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
Audit D4-R2: a figure whose size is in a section, not in its parts -- here a seat's label -- gave an outline of 132,709
|
|
99
|
+
characters against a budget of 100,000, and no room for any part. The outline leaves such a section out by its pointer
|
|
100
|
+
and the entries inside it; the model reads what it needs of it by pointer.
|
|
101
|
+
*/
|
|
102
|
+
describe('a section too large for the outline', () => {
|
|
103
|
+
const BINDINGS = '/document/capabilities/bindings'
|
|
104
|
+
|
|
105
|
+
it('the kernel takes the figure, and its view, every field counted, fits the budget with room for a part', () => {
|
|
106
|
+
expect(() => parseSource(serializeFigureAsset(labelled))).not.toThrow()
|
|
107
|
+
expect(size(labelled)).toBeGreaterThan(FIGURE_CONTEXT_BUDGET)
|
|
108
|
+
const view = figureViewOf(labelled, { parts: ['shelf-2'] })
|
|
109
|
+
expect(JSON.stringify(view).length).toBeLessThanOrEqual(FIGURE_CONTEXT_BUDGET)
|
|
110
|
+
expect(view.shown).toEqual(['shelf-2'])
|
|
111
|
+
const omitted = view.outline!.omitted!.find(o => o.section === '/document/capabilities')!
|
|
112
|
+
expect(omitted.chars).toBeGreaterThan(130_000)
|
|
113
|
+
expect(omitted.entries).toContainEqual({ section: BINDINGS, chars: expect.any(Number) })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('a section is read by its pointer; one too large says which entries to read instead', () => {
|
|
117
|
+
const view = figureViewOf(labelled, { sections: ['/document/capabilities/version', BINDINGS, `${BINDINGS}/0/accepts`] })
|
|
118
|
+
expect(view.sections).toEqual({ '/document/capabilities/version': labelled.document.capabilities.version, [`${BINDINGS}/0/accepts`]: ['box'] })
|
|
119
|
+
const big = view.notRead!.find(n => n.section === BINDINGS)!
|
|
120
|
+
expect(big.reason).toMatch(/too large to show at once/)
|
|
121
|
+
expect(big.reason).toContain(`${BINDINGS}/0`)
|
|
122
|
+
const label = figureViewOf(labelled, { sections: [`${BINDINGS}/0/label`] }).notRead![0]
|
|
123
|
+
expect(label.reason).toMatch(/one value of 130002 characters/) /* the label and its two quotes */
|
|
124
|
+
expect(figureViewOf(labelled, { sections: ['/nowhere'] }).notRead![0].reason).toMatch(/no section/)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a section left out and not read is not changed; a new entry beside it may be added', () => {
|
|
128
|
+
const view = figureViewOf(labelled, { parts: ['shelf-2'] })
|
|
129
|
+
const relabel = applyFigureProposal(labelled, [{ kind: 'remove-binding', id: 'seat' }] as never)
|
|
130
|
+
expect(unreadSectionEdits(labelled, relabel, view)).toEqual([expect.stringMatching(/^UNREAD_SECTION: .*\/document\/capabilities\/bindings\/0/)])
|
|
131
|
+
const added = applyFigureProposal(labelled, [
|
|
132
|
+
{ kind: 'add-binding', id: 'grip', label: 'Grip', role: 'grip', part: 'shelf-1', face: 'top', accepts: ['box'], capacity: 1 }
|
|
133
|
+
] as never)
|
|
134
|
+
expect(unreadSectionEdits(labelled, added, view)).toEqual([])
|
|
135
|
+
const read = figureViewOf(labelled, { parts: ['shelf-2'], sections: [`${BINDINGS}/0/accepts`, `${BINDINGS}/0/capacity`] })
|
|
136
|
+
const widened = structuredClone(labelled) as any
|
|
137
|
+
widened.document.capabilities.bindings[0].capacity = 2
|
|
138
|
+
expect(unreadSectionEdits(labelled, widened, read)).toEqual([])
|
|
139
|
+
widened.document.capabilities.bindings[0].role = 'grip'
|
|
140
|
+
expect(unreadSectionEdits(labelled, widened, read)).toHaveLength(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('a figure of many long-named parts still gets an outline inside the budget, saying how many it could not list', () => {
|
|
144
|
+
const many = structuredClone(under) as any
|
|
145
|
+
const look = many.appearance[0]
|
|
146
|
+
many.appearance = Array.from({ length: 3_000 }, (_, i) => ({ ...look, target: `part-with-a-rather-long-name-${'x'.repeat(40)}-${i}` }))
|
|
147
|
+
const view = figureViewOf(many)
|
|
148
|
+
expect(JSON.stringify(view).length).toBeLessThanOrEqual(FIGURE_CONTEXT_BUDGET)
|
|
149
|
+
expect(view.outline!.partsNotListed).toBeGreaterThan(0)
|
|
150
|
+
expect(view.outline!.parts.length + view.outline!.partsNotListed!).toBe(3_000)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('a proposal changes only what it was shown', () => {
|
|
155
|
+
let shown: ReturnType<typeof figureViewOf>
|
|
156
|
+
beforeAll(() => {
|
|
157
|
+
shown = figureViewOf(over, { parts: ['shelf-2'] })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('changing a part not shown is refused, naming the step and the part', () => {
|
|
161
|
+
const reasons = unreadPartEdits(over, [{ kind: 'set-material', target: 'shelf-5', material: { token: 'palette.primary' } }] as never, shown)
|
|
162
|
+
expect(reasons).toEqual([expect.stringMatching(/^UNREAD_PART: steps\[0\] \(set-material\) changes "shelf-5"/)])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('a joint is its moving part’s: refastening or removing it changes that part', () => {
|
|
166
|
+
const set = { kind: 'set-joint', joint: { id: 'shelf-5', type: 'fixed', body0: { part: 'shelf-2', face: 'top' }, body1: { part: 'shelf-5', face: 'bottom' }, origin: {} } }
|
|
167
|
+
const remove = { kind: 'remove-joint', id: 'shelf-5' }
|
|
168
|
+
expect(unreadPartEdits(over, [set, remove] as never, shown)).toHaveLength(2)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/*
|
|
172
|
+
Audit D4-R1: the part a step changes is the field the kernel reads for its kind. `set-material` reads `target`; a
|
|
173
|
+
`part` beside it named a part that was read, and the unread one was recoloured.
|
|
174
|
+
*/
|
|
175
|
+
it('a field the kernel does not read for the kind does not stand in for the one it does', () => {
|
|
176
|
+
const decoys = { part: 'shelf-2', target: 'shelf-2', id: 'shelf-2' }
|
|
177
|
+
const steps = [
|
|
178
|
+
{ ...decoys, kind: 'set-material', target: 'shelf-5', material: { token: 'palette.primary' } },
|
|
179
|
+
{ ...decoys, kind: 'attach', part: 'shelf-5', face: 'bottom', to: { part: 'shelf-2', face: 'top' } },
|
|
180
|
+
{ ...decoys, kind: 'remove-joint', joint: 'shelf-2', id: 'shelf-5' },
|
|
181
|
+
{ ...decoys, kind: 'set-joint', joint: { id: 'shelf-2', type: 'fixed', body0: { part: 'shelf-2', face: 'top' }, body1: { part: 'shelf-5', face: 'bottom' }, origin: {} } }
|
|
182
|
+
]
|
|
183
|
+
const reasons = unreadPartEdits(over, steps as never, shown)
|
|
184
|
+
expect(reasons).toHaveLength(steps.length)
|
|
185
|
+
reasons.forEach((reason, i) => expect(reason.startsWith(`UNREAD_PART: steps[${i}] (${steps[i].kind}) changes "shelf-5"`)).toBe(true))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('every kind the grammar teaches is judged; a kind the judge does not know is refused, not let through', () => {
|
|
189
|
+
for (const kind of FIGURE_PROPOSAL_KINDS) expect(unreadPartEdits(over, [{ kind }] as never, shown).join()).not.toMatch(/UNKNOWN_STEP/)
|
|
190
|
+
expect(unreadPartEdits(over, [{ kind: 'recolour-anything', target: 'shelf-5' }] as never, shown)).toEqual([expect.stringMatching(/^UNKNOWN_STEP: steps\[0\]/)])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('a shown part may change, a new part may be fastened to any part, the figure as a whole may change', () => {
|
|
194
|
+
const steps = [
|
|
195
|
+
{ kind: 'set-material', target: 'shelf-2', material: { token: 'palette.primary' } },
|
|
196
|
+
{ kind: 'add-part', part: { id: 'lamp', kind: 'sphere', dimensions: { radiusX: 40, radiusY: 40, radiusZ: 40 } } },
|
|
197
|
+
{ kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'shelf-9', face: 'top' } },
|
|
198
|
+
{ kind: 'link-dimension', part: 'lamp', axis: 'x', source: { of: 'part', part: 'shelf-7', axis: 'x' }, times: 0.1 },
|
|
199
|
+
{ kind: 'fit-room', placement: 'floor' }
|
|
200
|
+
]
|
|
201
|
+
expect(unreadPartEdits(over, steps as never, shown)).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('the judge refuses it before applying; without a shown set (whole source) it does not ask', () => {
|
|
205
|
+
const answer = { steps: [{ kind: 'set-material', target: 'shelf-5', material: { token: 'palette.primary' } }] }
|
|
206
|
+
expect(judgeFigureAnswer(over, answer, true, shown).reasons[0]).toMatch(/^UNREAD_PART/)
|
|
207
|
+
const decoy = { steps: [{ kind: 'set-material', part: 'shelf-2', target: 'shelf-5', material: { token: 'palette.primary' } }] }
|
|
208
|
+
expect(judgeFigureAnswer(over, decoy, true, shown).reasons[0]).toMatch(/^UNREAD_PART/)
|
|
209
|
+
expect(judgeFigureAnswer(over, answer, true).source).toBeDefined()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
function stubClient(answers: unknown[]): AIClient & { sent: any[][] } {
|
|
214
|
+
const client = {
|
|
215
|
+
id: 'stub',
|
|
216
|
+
sent: [] as any[][],
|
|
217
|
+
async chat() {
|
|
218
|
+
return ''
|
|
219
|
+
},
|
|
220
|
+
async generateJSON(messages: any[]) {
|
|
221
|
+
client.sent.push(JSON.parse(JSON.stringify(messages)))
|
|
222
|
+
return answers[client.sent.length - 1]
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return client as unknown as AIClient & { sent: any[][] }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
describe('the step writer is shown the same view', () => {
|
|
229
|
+
const recolour = (part: string) => ({ steps: [{ kind: 'set-material', target: part, material: { token: 'palette.primary' } }] })
|
|
230
|
+
|
|
231
|
+
it('just under: the whole source, and any part may change', async () => {
|
|
232
|
+
const client = stubClient([recolour('shelf-5')])
|
|
233
|
+
const result = await proposeFigureSteps({ client, prompt: 'recolour shelf 5', base: under })
|
|
234
|
+
expect(client.sent[0][0].content).toContain(serializeFigureAsset(under))
|
|
235
|
+
expect(result.attempts).toBe(1)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('just over: the outline and the parts asked for, never the whole; the prompt fits the budget', async () => {
|
|
239
|
+
const client = stubClient([recolour('shelf-2')])
|
|
240
|
+
const result = await proposeFigureSteps({ client, prompt: 'recolour shelf 2', base: over, read: { parts: ['shelf-2'] } })
|
|
241
|
+
const task = client.sent[0][0].content as string
|
|
242
|
+
expect(task).not.toContain(serializeFigureAsset(over))
|
|
243
|
+
expect(task).toContain('"shelf-2.shape"')
|
|
244
|
+
expect(task).not.toContain('"shelf-5.shape"')
|
|
245
|
+
expect(task).toMatch(/Parts shown in full: shelf-2\./)
|
|
246
|
+
expect(task.length).toBeLessThan(FIGURE_CONTEXT_BUDGET + 2_000)
|
|
247
|
+
/* Judged on the full draft: the recoloured shelf is in the figure the steps make, with every other one. */
|
|
248
|
+
expect(partsOf(result.source)).toEqual(partsOf(over))
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('the selected part is shown too: “this part” is one the writer has read', async () => {
|
|
252
|
+
const client = stubClient([recolour('shelf-4')])
|
|
253
|
+
await proposeFigureSteps({ client, prompt: 'recolour this', base: over, focus: { selection: { part: 'shelf-4' } } })
|
|
254
|
+
expect(client.sent[0][0].content).toContain('"shelf-4.shape"')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('a step on an unread part goes back to the writer as the reason; three times and the outer model is told to read it', async () => {
|
|
258
|
+
const client = stubClient([recolour('shelf-5'), recolour('shelf-5'), recolour('shelf-5')])
|
|
259
|
+
const failed = await proposeFigureSteps({ client, prompt: 'recolour shelf 5', base: over, read: { parts: ['shelf-2'] } }).catch(e => e)
|
|
260
|
+
expect(failed).toBeInstanceOf(ProposeFigureFailure)
|
|
261
|
+
expect(failed.reasons[0]).toMatch(/UNREAD_PART: steps\[0\] \(set-material\) changes "shelf-5".*getFigureSource.*parts/)
|
|
262
|
+
expect(client.sent[1].at(-1).content).toMatch(/UNREAD_PART/)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('the audit’s mixed step (part read, target not) is refused on the real path, and nothing is recoloured', async () => {
|
|
266
|
+
const mixed = { steps: [{ kind: 'set-material', part: 'shelf-0', target: 'shelf-5', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#ff0000' } }] }
|
|
267
|
+
const client = stubClient([mixed, mixed, mixed])
|
|
268
|
+
const failed = await proposeFigureSteps({ client, prompt: 'recolour', base: over, read: { parts: ['shelf-0'] } }).catch(e => e)
|
|
269
|
+
expect(failed).toBeInstanceOf(ProposeFigureFailure)
|
|
270
|
+
expect(failed.reasons[0]).toMatch(/UNREAD_PART: steps\[0\] \(set-material\) changes "shelf-5"/)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('a step whose effect reaches a section the writer was not shown goes back as the reason', async () => {
|
|
274
|
+
const client = stubClient([{ steps: [{ kind: 'remove-binding', id: 'seat' }] }, { steps: [{ kind: 'set-material', target: 'shelf-2', material: { token: 'palette.primary' } }] }])
|
|
275
|
+
const result = await proposeFigureSteps({ client, prompt: 'tidy', base: labelled, read: { parts: ['shelf-2'] } })
|
|
276
|
+
expect(client.sent[1].at(-1).content).toMatch(/UNREAD_SECTION/)
|
|
277
|
+
expect(result.attempts).toBe(2)
|
|
278
|
+
expect((client.sent[0][0].content as string).length).toBeLessThan(FIGURE_CONTEXT_BUDGET + 2_000)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('parts that are not there, or too many to show, fail before the writer is asked', async () => {
|
|
282
|
+
const client = stubClient([])
|
|
283
|
+
const failed = await proposeFigureSteps({ client, prompt: 'x', base: over, read: { parts: ['ghost'] } }).catch(e => e)
|
|
284
|
+
expect(failed).toBeInstanceOf(ProposeFigureFailure)
|
|
285
|
+
expect(failed.reasons.join('\n')).toMatch(/ghost.*not a part/)
|
|
286
|
+
expect(client.sent).toHaveLength(0)
|
|
287
|
+
})
|
|
288
|
+
})
|