@frockbot/plugin-image 0.0.0 → 0.1.0
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/frockbot.json +43 -0
- package/package.json +33 -6
- package/src/agent.test.ts +422 -0
- package/src/agent.ts +628 -0
- package/src/bytes.test.ts +53 -0
- package/src/bytes.ts +125 -0
- package/src/index.ts +5 -0
- package/src/manifest.ts +3 -0
- package/src/model.ts +63 -0
- package/src/root.ts +84 -0
- package/src/testing.ts +145 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "image",
|
|
4
|
+
"displayName": "Image generation",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": { "frockbot": ">=0.0.1" },
|
|
7
|
+
"contributions": {
|
|
8
|
+
"runtime": { "entry": "./agent" }
|
|
9
|
+
},
|
|
10
|
+
"permissions": ["image:generate", "workspace:write"],
|
|
11
|
+
"configuration": {
|
|
12
|
+
"settings": [
|
|
13
|
+
{
|
|
14
|
+
"id": "model",
|
|
15
|
+
"schemaVersion": 1,
|
|
16
|
+
"scopes": ["user", "bot"],
|
|
17
|
+
"schema": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"title": "Image model",
|
|
20
|
+
"description": "The text-to-image model generate_image runs. Defaults to @cf/black-forest-labs/flux-1-schnell.",
|
|
21
|
+
"enum": [
|
|
22
|
+
"@cf/black-forest-labs/flux-1-schnell",
|
|
23
|
+
"@cf/black-forest-labs/flux-2-klein-4b",
|
|
24
|
+
"@cf/stabilityai/stable-diffusion-xl-base-1.0",
|
|
25
|
+
"@cf/bytedance/stable-diffusion-xl-lightning"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"connectionTypes": [],
|
|
31
|
+
"capabilities": [
|
|
32
|
+
{
|
|
33
|
+
"id": "image-generation",
|
|
34
|
+
"kind": "tool",
|
|
35
|
+
"connectionTypes": [],
|
|
36
|
+
"admission": {
|
|
37
|
+
"turnTypes": ["chat", "automation", "subagent"],
|
|
38
|
+
"subagentRoles": ["executor"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./agent": "./src/agent.ts",
|
|
9
|
+
"./bytes": "./src/bytes.ts",
|
|
10
|
+
"./manifest": "./src/manifest.ts",
|
|
11
|
+
"./model": "./src/model.ts",
|
|
12
|
+
"./root": "./src/root.ts",
|
|
13
|
+
"./testing": "./src/testing.ts",
|
|
14
|
+
"./frockbot.json": "./frockbot.json",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"frockbot": {
|
|
18
|
+
"manifest": "./frockbot.json"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "bun test src",
|
|
22
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@frockbot/kernel-agent-loop": "0.1.0",
|
|
26
|
+
"@frockbot/kernel-contracts": "0.1.0",
|
|
27
|
+
"cordis": "4.0.0-rc.8"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "1.4.0",
|
|
31
|
+
"typescript": "^7.0.2"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
6
36
|
"repository": {
|
|
7
37
|
"type": "git",
|
|
8
38
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
39
|
"directory": "packages/plugin-image"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
40
|
}
|
|
14
41
|
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SessionStore, type Session } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { Context } from "cordis";
|
|
4
|
+
import {
|
|
5
|
+
createGenerateImageTool,
|
|
6
|
+
decodeGenerateImageInputV1,
|
|
7
|
+
type GenerateImageResultV1,
|
|
8
|
+
IMAGE_RESULT_ROOT_V1,
|
|
9
|
+
type ImageRuntimeHostV1,
|
|
10
|
+
} from "./agent.ts";
|
|
11
|
+
import { DEFAULT_IMAGE_MODEL_V1 } from "./model.ts";
|
|
12
|
+
import { generatedImagePathV1 } from "./root.ts";
|
|
13
|
+
import {
|
|
14
|
+
FakeImageModel,
|
|
15
|
+
FakeImageWorkspace,
|
|
16
|
+
fakePngBytesV1,
|
|
17
|
+
} from "./testing.ts";
|
|
18
|
+
|
|
19
|
+
const OWNER = { userId: "user-1", botId: "bot-1" };
|
|
20
|
+
const WRITER = { sessionId: "user-1:bot-1", turnId: "turn-4", runId: "run-9" };
|
|
21
|
+
const EFFECT_ID = "tool:4:2:0";
|
|
22
|
+
|
|
23
|
+
const CONTEXT = {
|
|
24
|
+
botId: "bot-1",
|
|
25
|
+
agentId: "bot-1",
|
|
26
|
+
sessionId: "user-1:bot-1",
|
|
27
|
+
compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
|
|
28
|
+
turnType: "chat" as const,
|
|
29
|
+
effectId: EFFECT_ID,
|
|
30
|
+
signal: new AbortController().signal,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function openSession(): Promise<{
|
|
34
|
+
session: Session;
|
|
35
|
+
sessions: { get(id: string): Session | undefined };
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
}> {
|
|
38
|
+
const root = new Context();
|
|
39
|
+
await root.plugin(SessionStore);
|
|
40
|
+
const session = root.sessions.create("user-1:bot-1");
|
|
41
|
+
session.appendBatch([
|
|
42
|
+
{ type: "turn/start", turn: 4 },
|
|
43
|
+
{ type: "step/start", turn: 4, step: 2 },
|
|
44
|
+
]);
|
|
45
|
+
return {
|
|
46
|
+
session,
|
|
47
|
+
sessions: root.sessions,
|
|
48
|
+
dispose: () => root.fiber.dispose(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function host(overrides: Partial<ImageRuntimeHostV1> = {}): ImageRuntimeHostV1 {
|
|
53
|
+
return { owner: OWNER, writer: WRITER, ...overrides };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("generate_image input", () => {
|
|
57
|
+
test("accepts a bounded prompt and defaults the size", () => {
|
|
58
|
+
expect(decodeGenerateImageInputV1({ prompt: "a red barn" })).toEqual({
|
|
59
|
+
prompt: "a red barn",
|
|
60
|
+
width: 1024,
|
|
61
|
+
height: 1024,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("refuses an empty, oversized, control-laden or unknown input", () => {
|
|
66
|
+
for (const input of [
|
|
67
|
+
undefined,
|
|
68
|
+
"a red barn",
|
|
69
|
+
{ prompt: "" },
|
|
70
|
+
{ prompt: " " },
|
|
71
|
+
{ prompt: "x".repeat(2_001) },
|
|
72
|
+
{ prompt: "a\u0007b" },
|
|
73
|
+
{ prompt: "a red barn", width: 640 },
|
|
74
|
+
{ prompt: "a red barn", height: 0 },
|
|
75
|
+
{ prompt: "a red barn", n: 2 },
|
|
76
|
+
{ prompt: "a red barn", seed: 1 },
|
|
77
|
+
]) {
|
|
78
|
+
expect(() => decodeGenerateImageInputV1(input)).toThrow();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("accepts both declared sizes", () => {
|
|
83
|
+
expect(
|
|
84
|
+
decodeGenerateImageInputV1({ prompt: "a", width: 512, height: 512 }),
|
|
85
|
+
).toMatchObject({ width: 512, height: 512 });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("generate_image", () => {
|
|
90
|
+
test("records intent before the model runs, and the generation after", async () => {
|
|
91
|
+
const { session, sessions, dispose } = await openSession();
|
|
92
|
+
const model = new FakeImageModel(fakePngBytesV1(1024, 1024));
|
|
93
|
+
const files = new FakeImageWorkspace();
|
|
94
|
+
// The order is what is asserted, so the model records where in the log it
|
|
95
|
+
// was called: an intent appended after the call would prove nothing.
|
|
96
|
+
const ordered: string[] = [];
|
|
97
|
+
const observed = {
|
|
98
|
+
run: (
|
|
99
|
+
id: string,
|
|
100
|
+
input: { prompt: string; width: number; height: number },
|
|
101
|
+
) => {
|
|
102
|
+
ordered.push(
|
|
103
|
+
`run:${session.events.filter((event) => event.type === "image/generate-intent").length}`,
|
|
104
|
+
);
|
|
105
|
+
return model.run(id, input);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const tool = createGenerateImageTool(
|
|
109
|
+
host({ model: observed, files }),
|
|
110
|
+
sessions,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
114
|
+
expect(result.isError).toBe(false);
|
|
115
|
+
|
|
116
|
+
// The model ran with exactly one intent already in the log.
|
|
117
|
+
expect(ordered).toEqual(["run:1"]);
|
|
118
|
+
const types = session.events.map((event) => event.type);
|
|
119
|
+
expect(types.indexOf("image/generate-intent")).toBeLessThan(
|
|
120
|
+
types.indexOf("image/generated"),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const intent = session.events.find(
|
|
124
|
+
(event) => event.type === "image/generate-intent",
|
|
125
|
+
);
|
|
126
|
+
expect(intent).toMatchObject({
|
|
127
|
+
turn: 4,
|
|
128
|
+
step: 2,
|
|
129
|
+
effectId: EFFECT_ID,
|
|
130
|
+
model: DEFAULT_IMAGE_MODEL_V1,
|
|
131
|
+
width: 1024,
|
|
132
|
+
height: 1024,
|
|
133
|
+
});
|
|
134
|
+
// The prompt reaches the log through `tool/call`; the intent carries only
|
|
135
|
+
// its hash, so the fence never becomes a second copy of the input.
|
|
136
|
+
expect(JSON.stringify(intent)).not.toContain("a red barn");
|
|
137
|
+
|
|
138
|
+
const parsed = JSON.parse(result.content) as GenerateImageResultV1;
|
|
139
|
+
expect(parsed).toMatchObject({
|
|
140
|
+
root: IMAGE_RESULT_ROOT_V1,
|
|
141
|
+
mimeType: "image/png",
|
|
142
|
+
width: 1024,
|
|
143
|
+
height: 1024,
|
|
144
|
+
});
|
|
145
|
+
expect(parsed.path).toBe(
|
|
146
|
+
generatedImagePathV1(OWNER, EFFECT_ID, "png").path,
|
|
147
|
+
);
|
|
148
|
+
expect(parsed.generationId).toBe("gen-0001");
|
|
149
|
+
// Never the bytes: a base64 image would be replayed into every later
|
|
150
|
+
// model request of the Turn.
|
|
151
|
+
expect(result.content).not.toContain("iVBOR");
|
|
152
|
+
expect(result.content.length).toBeLessThan(400);
|
|
153
|
+
|
|
154
|
+
expect(
|
|
155
|
+
session.events.find((event) => event.type === "image/generated"),
|
|
156
|
+
).toMatchObject({
|
|
157
|
+
effectId: EFFECT_ID,
|
|
158
|
+
path: parsed.path,
|
|
159
|
+
generationId: parsed.generationId,
|
|
160
|
+
contentHash: parsed.contentHash,
|
|
161
|
+
});
|
|
162
|
+
await dispose();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("records the dimensions of the bytes it stored, not the ones it asked for", async () => {
|
|
166
|
+
const { sessions, dispose } = await openSession();
|
|
167
|
+
// `flux-1-schnell` accepts no size at all, so a model that ignores the
|
|
168
|
+
// request is the ordinary case, not the exception.
|
|
169
|
+
const model = new FakeImageModel(fakePngBytesV1(1024, 1024));
|
|
170
|
+
const tool = createGenerateImageTool(
|
|
171
|
+
host({ model, files: new FakeImageWorkspace() }),
|
|
172
|
+
sessions,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const result = await tool.execute(
|
|
176
|
+
{ prompt: "a red barn", width: 512, height: 512 },
|
|
177
|
+
CONTEXT,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(model.calls[0]?.input).toEqual({
|
|
181
|
+
prompt: "a red barn",
|
|
182
|
+
width: 512,
|
|
183
|
+
height: 512,
|
|
184
|
+
});
|
|
185
|
+
expect(JSON.parse(result.content)).toMatchObject({
|
|
186
|
+
width: 1024,
|
|
187
|
+
height: 1024,
|
|
188
|
+
});
|
|
189
|
+
await dispose();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("attributes the write to the Bot, its Session and its Turn", async () => {
|
|
193
|
+
const { sessions, dispose } = await openSession();
|
|
194
|
+
const files = new FakeImageWorkspace();
|
|
195
|
+
const tool = createGenerateImageTool(
|
|
196
|
+
host({ model: new FakeImageModel(), files }),
|
|
197
|
+
sessions,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
201
|
+
|
|
202
|
+
const stored = await files.read(
|
|
203
|
+
generatedImagePathV1(OWNER, EFFECT_ID, "png"),
|
|
204
|
+
);
|
|
205
|
+
expect(stored.status).toBe("ok");
|
|
206
|
+
expect(
|
|
207
|
+
stored.status === "ok" ? stored.file.generation.writer : undefined,
|
|
208
|
+
).toEqual({
|
|
209
|
+
kind: "bot",
|
|
210
|
+
botId: "bot-1",
|
|
211
|
+
sessionId: "user-1:bot-1",
|
|
212
|
+
turnId: "turn-4",
|
|
213
|
+
runId: "run-9",
|
|
214
|
+
});
|
|
215
|
+
await dispose();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("refuses visibly when the host has no image model binding", async () => {
|
|
219
|
+
const { session, sessions, dispose } = await openSession();
|
|
220
|
+
const tool = createGenerateImageTool(
|
|
221
|
+
host({ files: new FakeImageWorkspace() }),
|
|
222
|
+
sessions,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
226
|
+
|
|
227
|
+
expect(result.isError).toBe(true);
|
|
228
|
+
expect(result.content).toContain("no image model binding");
|
|
229
|
+
// Nothing was promised: an intent for an effect that can never run would
|
|
230
|
+
// leave a fence around nothing.
|
|
231
|
+
expect(
|
|
232
|
+
session.events.some((event) => event.type === "image/generate-intent"),
|
|
233
|
+
).toBe(false);
|
|
234
|
+
await dispose();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("refuses visibly when the Workspace is unavailable", async () => {
|
|
238
|
+
const { sessions, dispose } = await openSession();
|
|
239
|
+
const model = new FakeImageModel();
|
|
240
|
+
const tool = createGenerateImageTool(host({ model }), sessions);
|
|
241
|
+
|
|
242
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
243
|
+
|
|
244
|
+
expect(result.isError).toBe(true);
|
|
245
|
+
expect(result.content).toContain("Workspace file surface is unavailable");
|
|
246
|
+
expect(model.calls).toHaveLength(0);
|
|
247
|
+
await dispose();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("refuses bytes that are not an image it can identify", async () => {
|
|
251
|
+
const { sessions, dispose } = await openSession();
|
|
252
|
+
const files = new FakeImageWorkspace();
|
|
253
|
+
const model = new FakeImageModel(new TextEncoder().encode("not an image"));
|
|
254
|
+
const tool = createGenerateImageTool(host({ model, files }), sessions);
|
|
255
|
+
|
|
256
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
257
|
+
|
|
258
|
+
expect(result.isError).toBe(true);
|
|
259
|
+
expect(result.content).toContain("neither a PNG nor a JPEG");
|
|
260
|
+
// Unidentifiable bytes never reach a durable root.
|
|
261
|
+
expect(files.calls.some((call) => call.startsWith("write:"))).toBe(false);
|
|
262
|
+
await dispose();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("refuses an image larger than a Workspace file may be", async () => {
|
|
266
|
+
const { sessions, dispose } = await openSession();
|
|
267
|
+
const oversized = new Uint8Array(1_048_577);
|
|
268
|
+
oversized.set(fakePngBytesV1(1024, 1024), 0);
|
|
269
|
+
const tool = createGenerateImageTool(
|
|
270
|
+
host({
|
|
271
|
+
model: new FakeImageModel(oversized),
|
|
272
|
+
files: new FakeImageWorkspace(),
|
|
273
|
+
}),
|
|
274
|
+
sessions,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
278
|
+
|
|
279
|
+
expect(result.isError).toBe(true);
|
|
280
|
+
expect(result.content).toContain("may not exceed");
|
|
281
|
+
await dispose();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("carries a model failure into durable state instead of throwing", async () => {
|
|
285
|
+
const { session, sessions, dispose } = await openSession();
|
|
286
|
+
const model = new FakeImageModel();
|
|
287
|
+
model.failure = "NSFW filter tripped";
|
|
288
|
+
const tool = createGenerateImageTool(
|
|
289
|
+
host({ model, files: new FakeImageWorkspace() }),
|
|
290
|
+
sessions,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
294
|
+
|
|
295
|
+
expect(result.isError).toBe(true);
|
|
296
|
+
expect(result.content).toContain("NSFW filter tripped");
|
|
297
|
+
// The intent stands: the effect was attempted, and reconciliation can now
|
|
298
|
+
// prove it produced nothing.
|
|
299
|
+
expect(
|
|
300
|
+
session.events.some((event) => event.type === "image/generate-intent"),
|
|
301
|
+
).toBe(true);
|
|
302
|
+
expect(
|
|
303
|
+
session.events.some((event) => event.type === "image/generated"),
|
|
304
|
+
).toBe(false);
|
|
305
|
+
await dispose();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("refuses a model the Package does not offer", async () => {
|
|
309
|
+
const { sessions, dispose } = await openSession();
|
|
310
|
+
const model = new FakeImageModel();
|
|
311
|
+
const tool = createGenerateImageTool(
|
|
312
|
+
host({ model, files: new FakeImageWorkspace(), modelId: "@cf/llama" }),
|
|
313
|
+
sessions,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const result = await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
317
|
+
|
|
318
|
+
expect(result.isError).toBe(true);
|
|
319
|
+
expect(result.content).toContain("not one this Package offers");
|
|
320
|
+
expect(model.calls).toHaveLength(0);
|
|
321
|
+
await dispose();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("reconciling a generated image", () => {
|
|
326
|
+
test("is never retried by the registry: the effect is not idempotent", async () => {
|
|
327
|
+
const { sessions, dispose } = await openSession();
|
|
328
|
+
const tool = createGenerateImageTool(
|
|
329
|
+
host({ model: new FakeImageModel(), files: new FakeImageWorkspace() }),
|
|
330
|
+
sessions,
|
|
331
|
+
);
|
|
332
|
+
expect(tool.idempotent).toBe(false);
|
|
333
|
+
expect(tool.reconcile).toBeDefined();
|
|
334
|
+
await dispose();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("returns the stored image after eviction without a second run()", async () => {
|
|
338
|
+
const files = new FakeImageWorkspace();
|
|
339
|
+
const model = new FakeImageModel();
|
|
340
|
+
|
|
341
|
+
// The Turn that generated the image.
|
|
342
|
+
const first = await openSession();
|
|
343
|
+
const before = createGenerateImageTool(
|
|
344
|
+
host({ model, files }),
|
|
345
|
+
first.sessions,
|
|
346
|
+
);
|
|
347
|
+
const original = await before.execute({ prompt: "a red barn" }, CONTEXT);
|
|
348
|
+
expect(original.isError).toBe(false);
|
|
349
|
+
await first.dispose();
|
|
350
|
+
|
|
351
|
+
// Eviction: a fresh Package instance over the same durable Workspace, and
|
|
352
|
+
// a session that replays only the intent — the outcome was never recorded.
|
|
353
|
+
const second = await openSession();
|
|
354
|
+
second.session.append({
|
|
355
|
+
type: "image/generate-intent",
|
|
356
|
+
turn: 4,
|
|
357
|
+
step: 2,
|
|
358
|
+
effectId: EFFECT_ID,
|
|
359
|
+
model: DEFAULT_IMAGE_MODEL_V1,
|
|
360
|
+
promptHash: "0".repeat(64),
|
|
361
|
+
width: 1024,
|
|
362
|
+
height: 1024,
|
|
363
|
+
});
|
|
364
|
+
const after = createGenerateImageTool(
|
|
365
|
+
host({ model, files }),
|
|
366
|
+
second.sessions,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const reconciliation = await after.reconcile!(
|
|
370
|
+
{ prompt: "a red barn" },
|
|
371
|
+
CONTEXT,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(reconciliation.status).toBe("recovered");
|
|
375
|
+
expect(
|
|
376
|
+
reconciliation.status === "recovered"
|
|
377
|
+
? JSON.parse(reconciliation.result.content)
|
|
378
|
+
: undefined,
|
|
379
|
+
).toEqual(JSON.parse(original.content));
|
|
380
|
+
// The whole point: recovery read the Workspace and never billed again.
|
|
381
|
+
expect(model.calls).toHaveLength(1);
|
|
382
|
+
// And the outcome the interrupted attempt never recorded is recorded now.
|
|
383
|
+
expect(
|
|
384
|
+
second.session.events.find((event) => event.type === "image/generated"),
|
|
385
|
+
).toMatchObject({ effectId: EFFECT_ID, turn: 4, step: 2 });
|
|
386
|
+
await second.dispose();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("is unavailable, not a silent retry, when nothing was stored", async () => {
|
|
390
|
+
const { sessions, dispose } = await openSession();
|
|
391
|
+
const model = new FakeImageModel();
|
|
392
|
+
const tool = createGenerateImageTool(
|
|
393
|
+
host({ model, files: new FakeImageWorkspace() }),
|
|
394
|
+
sessions,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const reconciliation = await tool.reconcile!(
|
|
398
|
+
{ prompt: "a red barn" },
|
|
399
|
+
CONTEXT,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(reconciliation).toMatchObject({ status: "unavailable" });
|
|
403
|
+
expect(model.calls).toHaveLength(0);
|
|
404
|
+
await dispose();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("records the outcome only once across a reconciled replay", async () => {
|
|
408
|
+
const files = new FakeImageWorkspace();
|
|
409
|
+
const model = new FakeImageModel();
|
|
410
|
+
const { session, sessions, dispose } = await openSession();
|
|
411
|
+
const tool = createGenerateImageTool(host({ model, files }), sessions);
|
|
412
|
+
|
|
413
|
+
await tool.execute({ prompt: "a red barn" }, CONTEXT);
|
|
414
|
+
await tool.reconcile!({ prompt: "a red barn" }, CONTEXT);
|
|
415
|
+
|
|
416
|
+
expect(
|
|
417
|
+
session.events.filter((event) => event.type === "image/generated"),
|
|
418
|
+
).toHaveLength(1);
|
|
419
|
+
expect(model.calls).toHaveLength(1);
|
|
420
|
+
await dispose();
|
|
421
|
+
});
|
|
422
|
+
});
|